From d5c46fcb3310c977742a73c473e513dbde0b75da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Tue, 9 Sep 2025 11:49:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor(src):=20=E9=87=8D=E6=9E=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 app、modules、shared、presentation、infrastructure 五个顶级目录 - 将配置、核心引导和日志迁移至 app 目录 - 按业务领域划分模块,controllers、services、models 同步重组到 modules 内 - 共享工具、常量和基础类统一放入 shared 目录 - 中间件、路由和视图迁移至 presentation 目录下 - 数据库和定时任务迁移至 infrastructure 目录管理 - 新增数据库服务提供者封装连接和缓存逻辑 - 更新所有相关导入路径适配新目录结构 - 删除废弃的老目录和控制器代码 - 重构 main.js 异步启动服务器逻辑,完成插件注册和日志打印 - 保持原有 MVC 分层与路由自动发现机制,提升代码组织和维护性 --- .qoder/quests/clean-src-directory.md | 330 +++++++++++ src/app/bootstrap/app.js | 27 + src/app/bootstrap/index.js | 22 + src/app/bootstrap/logger.js | 68 +++ src/app/config/database.js | 55 ++ src/app/config/index.js | 3 + src/app/providers/DatabaseProvider.js | 216 +++++++ src/app/providers/index.js | 15 + src/config/index.js | 3 - src/controllers/Api/ApiController.js | 58 -- src/controllers/Api/AuthController.js | 45 -- src/controllers/Api/JobController.js | 46 -- src/controllers/Api/StatusController.js | 20 - src/controllers/Page/ArticleController.js | 130 ----- src/controllers/Page/AuthPageController.js | 136 ----- src/controllers/Page/BasePageController.js | 102 ---- src/controllers/Page/ProfileController.js | 228 -------- src/controllers/Page/UploadController.js | 200 ------- src/controllers/Page/_Demo/HtmxController.js | 63 --- src/db/docs/ArticleModel.md | 190 ------- src/db/docs/BookmarkModel.md | 194 ------- src/db/docs/README.md | 252 --------- src/db/docs/SiteConfigModel.md | 246 -------- src/db/docs/UserModel.md | 158 ------ src/db/index.js | 149 ----- .../20250616065041_create_users_table.mjs | 25 - src/db/migrations/20250621013128_site_config.mjs | 21 - .../20250830014825_create_articles_table.mjs | 26 - .../20250830015422_create_bookmarks_table.mjs | 25 - .../20250830020000_add_article_fields.mjs | 60 -- .../20250901000000_add_profile_fields.mjs | 25 - src/db/models/ArticleModel.js | 290 ---------- src/db/models/BookmarkModel.js | 68 --- src/db/models/SiteConfigModel.js | 42 -- src/db/models/UserModel.js | 36 -- src/db/seeds/20250616071157_users_seed.mjs | 17 - src/db/seeds/20250621013324_site_config_seed.mjs | 15 - src/db/seeds/20250830020000_articles_seed.mjs | 77 --- src/global.js | 21 - src/infrastructure/database/docs/ArticleModel.md | 190 +++++++ src/infrastructure/database/docs/BookmarkModel.md | 194 +++++++ src/infrastructure/database/docs/README.md | 252 +++++++++ .../database/docs/SiteConfigModel.md | 246 ++++++++ src/infrastructure/database/docs/UserModel.md | 158 ++++++ src/infrastructure/database/index.js | 13 + .../20250616065041_create_users_table.mjs | 25 + .../migrations/20250621013128_site_config.mjs | 21 + .../20250830014825_create_articles_table.mjs | 26 + .../20250830015422_create_bookmarks_table.mjs | 25 + .../20250830020000_add_article_fields.mjs | 60 ++ .../20250901000000_add_profile_fields.mjs | 25 + .../database/seeds/20250616071157_users_seed.mjs | 17 + .../seeds/20250621013324_site_config_seed.mjs | 15 + .../seeds/20250830020000_articles_seed.mjs | 77 +++ src/infrastructure/jobs/exampleJob.js | 11 + src/infrastructure/jobs/index.js | 62 ++ src/jobs/exampleJob.js | 11 - src/jobs/index.js | 48 -- src/logger.js | 63 --- src/main.js | 65 ++- src/middlewares/Auth/auth.js | 73 --- src/middlewares/Auth/index.js | 3 - src/middlewares/Auth/jwt.js | 3 - src/middlewares/ErrorHandler/index.js | 43 -- src/middlewares/ResponseTime/index.js | 63 --- src/middlewares/Send/index.js | 185 ------ src/middlewares/Send/resolve-path.js | 74 --- src/middlewares/Session/index.js | 15 - src/middlewares/Toast/index.js | 14 - src/middlewares/Views/index.js | 76 --- src/middlewares/errorHandler/index.js | 43 -- src/middlewares/install.js | 69 --- .../article/controllers/ArticleController.js | 130 +++++ src/modules/article/models/ArticleModel.js | 290 ++++++++++ src/modules/article/services/ArticleService.js | 295 ++++++++++ src/modules/auth/controllers/AuthController.js | 45 ++ src/modules/auth/controllers/AuthPageController.js | 136 +++++ src/modules/auth/models/UserModel.js | 36 ++ src/modules/auth/services/userService.js | 414 ++++++++++++++ src/modules/bookmark/models/BookmarkModel.js | 68 +++ src/modules/bookmark/services/BookmarkService.js | 312 ++++++++++ src/modules/common/controllers/ApiController.js | 58 ++ src/modules/common/controllers/JobController.js | 46 ++ src/modules/common/controllers/PageController.js | 25 + src/modules/common/controllers/StatusController.js | 20 + src/modules/common/controllers/UploadController.js | 200 +++++++ src/modules/common/services/JobService.js | 18 + src/modules/site-config/models/SiteConfigModel.js | 42 ++ .../site-config/services/SiteConfigService.js | 299 ++++++++++ src/modules/user/controllers/ProfileController.js | 228 ++++++++ src/presentation/middlewares/Auth/auth.js | 73 +++ src/presentation/middlewares/Auth/index.js | 3 + src/presentation/middlewares/Auth/jwt.js | 3 + src/presentation/middlewares/ErrorHandler/index.js | 43 ++ src/presentation/middlewares/ResponseTime/index.js | 63 +++ src/presentation/middlewares/Send/index.js | 185 ++++++ src/presentation/middlewares/Send/resolve-path.js | 74 +++ src/presentation/middlewares/Session/index.js | 15 + src/presentation/middlewares/Toast/index.js | 14 + src/presentation/middlewares/Views/index.js | 76 +++ src/presentation/middlewares/install.js | 70 +++ src/presentation/views/error/index.pug | 8 + src/presentation/views/htmx/footer.pug | 53 ++ src/presentation/views/htmx/login.pug | 13 + src/presentation/views/htmx/navbar.pug | 86 +++ src/presentation/views/htmx/timeline.pug | 140 +++++ src/presentation/views/layouts/base.pug | 58 ++ src/presentation/views/layouts/bg-page.pug | 18 + src/presentation/views/layouts/empty.pug | 122 ++++ src/presentation/views/layouts/page.pug | 31 + src/presentation/views/layouts/pure.pug | 16 + src/presentation/views/layouts/root.pug | 69 +++ src/presentation/views/layouts/utils.pug | 23 + src/presentation/views/page/about/index.pug | 20 + src/presentation/views/page/articles/article.pug | 70 +++ src/presentation/views/page/articles/category.pug | 29 + src/presentation/views/page/articles/index.pug | 134 +++++ src/presentation/views/page/articles/search.pug | 34 ++ src/presentation/views/page/articles/tag.pug | 32 ++ src/presentation/views/page/auth/no-auth.pug | 54 ++ src/presentation/views/page/extra/contact.pug | 83 +++ src/presentation/views/page/extra/faq.pug | 55 ++ src/presentation/views/page/extra/feedback.pug | 28 + src/presentation/views/page/extra/help.pug | 97 ++++ src/presentation/views/page/extra/privacy.pug | 75 +++ src/presentation/views/page/extra/terms.pug | 64 +++ src/presentation/views/page/index copy/index.pug | 10 + src/presentation/views/page/index/index copy 2.pug | 11 + src/presentation/views/page/index/index copy.pug | 17 + src/presentation/views/page/index/index.pug | 69 +++ src/presentation/views/page/index/person.pug | 9 + src/presentation/views/page/login/index.pug | 19 + src/presentation/views/page/notice/index.pug | 7 + src/presentation/views/page/profile/index.pug | 625 +++++++++++++++++++++ src/presentation/views/page/register/index.pug | 119 ++++ src/services/ArticleService.js | 295 ---------- src/services/BookmarkService.js | 312 ---------- src/services/JobService.js | 18 - src/services/README.md | 222 -------- src/services/SiteConfigService.js | 299 ---------- src/services/index.js | 36 -- src/services/userService.js | 414 -------------- src/shared/utils/BaseSingleton.js | 37 ++ src/shared/utils/ForRegister.js | 117 ++++ src/shared/utils/bcrypt.js | 11 + src/shared/utils/envValidator.js | 165 ++++++ src/shared/utils/error/CommonError.js | 7 + src/shared/utils/helper.js | 26 + src/shared/utils/router.js | 139 +++++ src/shared/utils/router/RouteAuth.js | 49 ++ src/shared/utils/scheduler.js | 60 ++ src/utils/BaseSingleton.js | 37 -- src/utils/ForRegister.js | 117 ---- src/utils/bcrypt.js | 11 - src/utils/envValidator.js | 165 ------ src/utils/error/CommonError.js | 7 - src/utils/helper.js | 26 - src/utils/router.js | 139 ----- src/utils/router/RouteAuth.js | 49 -- src/utils/scheduler.js | 60 -- src/views/error/index.pug | 8 - src/views/htmx/footer.pug | 53 -- src/views/htmx/login.pug | 13 - src/views/htmx/navbar.pug | 86 --- src/views/htmx/timeline.pug | 140 ----- src/views/layouts/base.pug | 58 -- src/views/layouts/bg-page.pug | 18 - src/views/layouts/empty.pug | 122 ---- src/views/layouts/page.pug | 31 - src/views/layouts/pure.pug | 16 - src/views/layouts/root.pug | 69 --- src/views/layouts/utils.pug | 23 - src/views/page/about/index.pug | 20 - src/views/page/articles/article.pug | 70 --- src/views/page/articles/category.pug | 29 - src/views/page/articles/index.pug | 134 ----- src/views/page/articles/search.pug | 34 -- src/views/page/articles/tag.pug | 32 -- src/views/page/auth/no-auth.pug | 54 -- src/views/page/extra/contact.pug | 83 --- src/views/page/extra/faq.pug | 55 -- src/views/page/extra/feedback.pug | 28 - src/views/page/extra/help.pug | 97 ---- src/views/page/extra/privacy.pug | 75 --- src/views/page/extra/terms.pug | 64 --- src/views/page/index copy/index.pug | 10 - src/views/page/index/index copy 2.pug | 11 - src/views/page/index/index copy.pug | 17 - src/views/page/index/index.pug | 69 --- src/views/page/index/person.pug | 9 - src/views/page/login/index.pug | 19 - src/views/page/notice/index.pug | 7 - src/views/page/profile/index.pug | 625 --------------------- src/views/page/register/index.pug | 119 ---- 194 files changed, 8381 insertions(+), 8283 deletions(-) create mode 100644 .qoder/quests/clean-src-directory.md create mode 100644 src/app/bootstrap/app.js create mode 100644 src/app/bootstrap/index.js create mode 100644 src/app/bootstrap/logger.js create mode 100644 src/app/config/database.js create mode 100644 src/app/config/index.js create mode 100644 src/app/providers/DatabaseProvider.js create mode 100644 src/app/providers/index.js delete mode 100644 src/config/index.js delete mode 100644 src/controllers/Api/ApiController.js delete mode 100644 src/controllers/Api/AuthController.js delete mode 100644 src/controllers/Api/JobController.js delete mode 100644 src/controllers/Api/StatusController.js delete mode 100644 src/controllers/Page/ArticleController.js delete mode 100644 src/controllers/Page/AuthPageController.js delete mode 100644 src/controllers/Page/BasePageController.js delete mode 100644 src/controllers/Page/ProfileController.js delete mode 100644 src/controllers/Page/UploadController.js delete mode 100644 src/controllers/Page/_Demo/HtmxController.js delete mode 100644 src/db/docs/ArticleModel.md delete mode 100644 src/db/docs/BookmarkModel.md delete mode 100644 src/db/docs/README.md delete mode 100644 src/db/docs/SiteConfigModel.md delete mode 100644 src/db/docs/UserModel.md delete mode 100644 src/db/index.js delete mode 100644 src/db/migrations/20250616065041_create_users_table.mjs delete mode 100644 src/db/migrations/20250621013128_site_config.mjs delete mode 100644 src/db/migrations/20250830014825_create_articles_table.mjs delete mode 100644 src/db/migrations/20250830015422_create_bookmarks_table.mjs delete mode 100644 src/db/migrations/20250830020000_add_article_fields.mjs delete mode 100644 src/db/migrations/20250901000000_add_profile_fields.mjs delete mode 100644 src/db/models/ArticleModel.js delete mode 100644 src/db/models/BookmarkModel.js delete mode 100644 src/db/models/SiteConfigModel.js delete mode 100644 src/db/models/UserModel.js delete mode 100644 src/db/seeds/20250616071157_users_seed.mjs delete mode 100644 src/db/seeds/20250621013324_site_config_seed.mjs delete mode 100644 src/db/seeds/20250830020000_articles_seed.mjs delete mode 100644 src/global.js create mode 100644 src/infrastructure/database/docs/ArticleModel.md create mode 100644 src/infrastructure/database/docs/BookmarkModel.md create mode 100644 src/infrastructure/database/docs/README.md create mode 100644 src/infrastructure/database/docs/SiteConfigModel.md create mode 100644 src/infrastructure/database/docs/UserModel.md create mode 100644 src/infrastructure/database/index.js create mode 100644 src/infrastructure/database/migrations/20250616065041_create_users_table.mjs create mode 100644 src/infrastructure/database/migrations/20250621013128_site_config.mjs create mode 100644 src/infrastructure/database/migrations/20250830014825_create_articles_table.mjs create mode 100644 src/infrastructure/database/migrations/20250830015422_create_bookmarks_table.mjs create mode 100644 src/infrastructure/database/migrations/20250830020000_add_article_fields.mjs create mode 100644 src/infrastructure/database/migrations/20250901000000_add_profile_fields.mjs create mode 100644 src/infrastructure/database/seeds/20250616071157_users_seed.mjs create mode 100644 src/infrastructure/database/seeds/20250621013324_site_config_seed.mjs create mode 100644 src/infrastructure/database/seeds/20250830020000_articles_seed.mjs create mode 100644 src/infrastructure/jobs/exampleJob.js create mode 100644 src/infrastructure/jobs/index.js delete mode 100644 src/jobs/exampleJob.js delete mode 100644 src/jobs/index.js delete mode 100644 src/logger.js delete mode 100644 src/middlewares/Auth/auth.js delete mode 100644 src/middlewares/Auth/index.js delete mode 100644 src/middlewares/Auth/jwt.js delete mode 100644 src/middlewares/ErrorHandler/index.js delete mode 100644 src/middlewares/ResponseTime/index.js delete mode 100644 src/middlewares/Send/index.js delete mode 100644 src/middlewares/Send/resolve-path.js delete mode 100644 src/middlewares/Session/index.js delete mode 100644 src/middlewares/Toast/index.js delete mode 100644 src/middlewares/Views/index.js delete mode 100644 src/middlewares/errorHandler/index.js delete mode 100644 src/middlewares/install.js create mode 100644 src/modules/article/controllers/ArticleController.js create mode 100644 src/modules/article/models/ArticleModel.js create mode 100644 src/modules/article/services/ArticleService.js create mode 100644 src/modules/auth/controllers/AuthController.js create mode 100644 src/modules/auth/controllers/AuthPageController.js create mode 100644 src/modules/auth/models/UserModel.js create mode 100644 src/modules/auth/services/userService.js create mode 100644 src/modules/bookmark/models/BookmarkModel.js create mode 100644 src/modules/bookmark/services/BookmarkService.js create mode 100644 src/modules/common/controllers/ApiController.js create mode 100644 src/modules/common/controllers/JobController.js create mode 100644 src/modules/common/controllers/PageController.js create mode 100644 src/modules/common/controllers/StatusController.js create mode 100644 src/modules/common/controllers/UploadController.js create mode 100644 src/modules/common/services/JobService.js create mode 100644 src/modules/site-config/models/SiteConfigModel.js create mode 100644 src/modules/site-config/services/SiteConfigService.js create mode 100644 src/modules/user/controllers/ProfileController.js create mode 100644 src/presentation/middlewares/Auth/auth.js create mode 100644 src/presentation/middlewares/Auth/index.js create mode 100644 src/presentation/middlewares/Auth/jwt.js create mode 100644 src/presentation/middlewares/ErrorHandler/index.js create mode 100644 src/presentation/middlewares/ResponseTime/index.js create mode 100644 src/presentation/middlewares/Send/index.js create mode 100644 src/presentation/middlewares/Send/resolve-path.js create mode 100644 src/presentation/middlewares/Session/index.js create mode 100644 src/presentation/middlewares/Toast/index.js create mode 100644 src/presentation/middlewares/Views/index.js create mode 100644 src/presentation/middlewares/install.js create mode 100644 src/presentation/views/error/index.pug create mode 100644 src/presentation/views/htmx/footer.pug create mode 100644 src/presentation/views/htmx/login.pug create mode 100644 src/presentation/views/htmx/navbar.pug create mode 100644 src/presentation/views/htmx/timeline.pug create mode 100644 src/presentation/views/layouts/base.pug create mode 100644 src/presentation/views/layouts/bg-page.pug create mode 100644 src/presentation/views/layouts/empty.pug create mode 100644 src/presentation/views/layouts/page.pug create mode 100644 src/presentation/views/layouts/pure.pug create mode 100644 src/presentation/views/layouts/root.pug create mode 100644 src/presentation/views/layouts/utils.pug create mode 100644 src/presentation/views/page/about/index.pug create mode 100644 src/presentation/views/page/articles/article.pug create mode 100644 src/presentation/views/page/articles/category.pug create mode 100644 src/presentation/views/page/articles/index.pug create mode 100644 src/presentation/views/page/articles/search.pug create mode 100644 src/presentation/views/page/articles/tag.pug create mode 100644 src/presentation/views/page/auth/no-auth.pug create mode 100644 src/presentation/views/page/extra/contact.pug create mode 100644 src/presentation/views/page/extra/faq.pug create mode 100644 src/presentation/views/page/extra/feedback.pug create mode 100644 src/presentation/views/page/extra/help.pug create mode 100644 src/presentation/views/page/extra/privacy.pug create mode 100644 src/presentation/views/page/extra/terms.pug create mode 100644 src/presentation/views/page/index copy/index.pug create mode 100644 src/presentation/views/page/index/index copy 2.pug create mode 100644 src/presentation/views/page/index/index copy.pug create mode 100644 src/presentation/views/page/index/index.pug create mode 100644 src/presentation/views/page/index/person.pug create mode 100644 src/presentation/views/page/login/index.pug create mode 100644 src/presentation/views/page/notice/index.pug create mode 100644 src/presentation/views/page/profile/index.pug create mode 100644 src/presentation/views/page/register/index.pug delete mode 100644 src/services/ArticleService.js delete mode 100644 src/services/BookmarkService.js delete mode 100644 src/services/JobService.js delete mode 100644 src/services/README.md delete mode 100644 src/services/SiteConfigService.js delete mode 100644 src/services/index.js delete mode 100644 src/services/userService.js create mode 100644 src/shared/utils/BaseSingleton.js create mode 100644 src/shared/utils/ForRegister.js create mode 100644 src/shared/utils/bcrypt.js create mode 100644 src/shared/utils/envValidator.js create mode 100644 src/shared/utils/error/CommonError.js create mode 100644 src/shared/utils/helper.js create mode 100644 src/shared/utils/router.js create mode 100644 src/shared/utils/router/RouteAuth.js create mode 100644 src/shared/utils/scheduler.js delete mode 100644 src/utils/BaseSingleton.js delete mode 100644 src/utils/ForRegister.js delete mode 100644 src/utils/bcrypt.js delete mode 100644 src/utils/envValidator.js delete mode 100644 src/utils/error/CommonError.js delete mode 100644 src/utils/helper.js delete mode 100644 src/utils/router.js delete mode 100644 src/utils/router/RouteAuth.js delete mode 100644 src/utils/scheduler.js delete mode 100644 src/views/error/index.pug delete mode 100644 src/views/htmx/footer.pug delete mode 100644 src/views/htmx/login.pug delete mode 100644 src/views/htmx/navbar.pug delete mode 100644 src/views/htmx/timeline.pug delete mode 100644 src/views/layouts/base.pug delete mode 100644 src/views/layouts/bg-page.pug delete mode 100644 src/views/layouts/empty.pug delete mode 100644 src/views/layouts/page.pug delete mode 100644 src/views/layouts/pure.pug delete mode 100644 src/views/layouts/root.pug delete mode 100644 src/views/layouts/utils.pug delete mode 100644 src/views/page/about/index.pug delete mode 100644 src/views/page/articles/article.pug delete mode 100644 src/views/page/articles/category.pug delete mode 100644 src/views/page/articles/index.pug delete mode 100644 src/views/page/articles/search.pug delete mode 100644 src/views/page/articles/tag.pug delete mode 100644 src/views/page/auth/no-auth.pug delete mode 100644 src/views/page/extra/contact.pug delete mode 100644 src/views/page/extra/faq.pug delete mode 100644 src/views/page/extra/feedback.pug delete mode 100644 src/views/page/extra/help.pug delete mode 100644 src/views/page/extra/privacy.pug delete mode 100644 src/views/page/extra/terms.pug delete mode 100644 src/views/page/index copy/index.pug delete mode 100644 src/views/page/index/index copy 2.pug delete mode 100644 src/views/page/index/index copy.pug delete mode 100644 src/views/page/index/index.pug delete mode 100644 src/views/page/index/person.pug delete mode 100644 src/views/page/login/index.pug delete mode 100644 src/views/page/notice/index.pug delete mode 100644 src/views/page/profile/index.pug delete mode 100644 src/views/page/register/index.pug diff --git a/.qoder/quests/clean-src-directory.md b/.qoder/quests/clean-src-directory.md new file mode 100644 index 0000000..4f62a59 --- /dev/null +++ b/.qoder/quests/clean-src-directory.md @@ -0,0 +1,330 @@ +# src 目录整理设计 + +## 概述 + +本设计旨在优化 koa3-demo 项目的 src 目录结构,在保持现有 MVC 架构不变的前提下,通过最小化的代码改动来提升代码组织的清晰度和可维护性。 + +## 当前目录结构分析 + +### 现状问题 +- 目录层级较为扁平,业务模块分散 +- controllers 按类型(Api/Page)分组,但缺乏按业务领域组织 +- services 和 models 分离在不同的顶级目录 +- 缺乏统一的模块组织规范 + +### 优势保持 +- MVC 架构清晰,分层明确 +- 中间件系统完善 +- 路由自动发现机制完整 +- 数据库操作层设计合理 + +## 整理方案 + +### 目标架构 + +采用**领域驱动的模块化架构**,在保持 MVC 分层的同时,按业务领域组织代码: + +```mermaid +graph TB + subgraph "src 目录结构" + A[src/] --> B[app/] + A --> C[modules/] + A --> D[shared/] + A --> E[presentation/] + + B --> B1[config/] + B --> B2[providers/] + B --> B3[bootstrap/] + + C --> C1[auth/] + C --> C2[article/] + C --> C3[user/] + C --> C4[site-config/] + + D --> D1[utils/] + D --> D2[constants/] + D --> D3[helpers/] + + E --> E1[middlewares/] + E --> E2[routes/] + E --> E3[views/] + + subgraph "模块内部结构" + C1 --> C1A[controllers/] + C1 --> C1B[services/] + C1 --> C1C[models/] + C1 --> C1D[validators/] + end + end +``` + +### 详细目录规划 + +#### 1. app/ - 应用核心 +``` +app/ +├── config/ # 配置管理 +│ ├── index.js # 当前 src/config/index.js +│ └── database.js # 数据库配置(从 db/ 提取) +├── providers/ # 服务提供者 +│ ├── DatabaseProvider.js # 数据库连接管理 +│ └── index.js # 提供者注册 +└── bootstrap/ # 应用引导 + ├── app.js # 应用实例(当前 global.js) + ├── logger.js # 日志配置 + └── index.js # 引导入口 +``` + +#### 2. modules/ - 业务模块 +``` +modules/ +├── auth/ # 认证模块 +│ ├── controllers/ +│ │ ├── AuthController.js # API 控制器 +│ │ └── AuthPageController.js # 页面控制器 +│ ├── services/ +│ │ └── AuthService.js # 从 userService.js 提取认证相关 +│ ├── models/ +│ │ └── UserModel.js # 用户模型 +│ └── validators/ +│ └── AuthValidator.js # 认证验证规则 +├── user/ # 用户管理模块 +│ ├── controllers/ +│ │ └── ProfileController.js # 用户资料控制器 +│ ├── services/ +│ │ └── UserService.js # 用户业务服务 +│ └── models/ +│ └── UserModel.js # 用户模型(共享) +├── article/ # 文章管理模块 +│ ├── controllers/ +│ │ └── ArticleController.js +│ ├── services/ +│ │ └── ArticleService.js +│ └── models/ +│ └── ArticleModel.js +├── bookmark/ # 书签管理模块 +│ ├── services/ +│ │ └── BookmarkService.js +│ └── models/ +│ └── BookmarkModel.js +├── site-config/ # 站点配置模块 +│ ├── services/ +│ │ └── SiteConfigService.js +│ └── models/ +│ └── SiteConfigModel.js +└── common/ # 通用模块 + ├── controllers/ + │ ├── ApiController.js + │ ├── StatusController.js + │ └── UploadController.js + └── services/ + └── JobService.js +``` + +#### 3. shared/ - 共享资源 +``` +shared/ +├── utils/ # 工具函数 +│ ├── error/ +│ ├── router/ +│ ├── bcrypt.js +│ ├── helper.js +│ └── scheduler.js +├── constants/ # 常量定义 +│ ├── errors.js +│ └── status.js +└── base/ # 基础类 + ├── BaseController.js + ├── BaseService.js + └── BaseModel.js +``` + +#### 4. presentation/ - 表现层 +``` +presentation/ +├── middlewares/ # 中间件(当前 middlewares/) +├── routes/ # 路由管理 +│ ├── api.js # API 路由 +│ ├── web.js # 页面路由 +│ └── index.js # 路由注册 +└── views/ # 视图模板(当前 views/) +``` + +#### 5. infrastructure/ - 基础设施 +``` +infrastructure/ +├── database/ # 数据库相关 +│ ├── migrations/ # 迁移文件 +│ ├── seeds/ # 种子数据 +│ ├── docs/ # 数据库文档 +│ └── index.js # 数据库连接 +└── jobs/ # 定时任务 + ├── exampleJob.js + └── index.js +``` + +## 迁移策略 + +### 阶段一:创建新目录结构 +1. 创建 `app/`, `modules/`, `shared/`, `presentation/`, `infrastructure/` 目录 +2. 建立各模块的子目录结构 + +### 阶段二:文件迁移与重组 +1. **配置文件迁移** + - `config/` → `app/config/` + - 从 `db/index.js` 提取数据库配置到 `app/config/database.js` + +2. **核心文件重组** + - `global.js` → `app/bootstrap/app.js` + - `logger.js` → `app/bootstrap/logger.js` + - 创建 `app/bootstrap/index.js` 统一引导 + +3. **业务模块化** + - 按业务领域创建模块目录 + - 将相关的 controllers, services, models 组织到对应模块 + +4. **基础设施迁移** + - `db/` → `infrastructure/database/` + - `jobs/` → `infrastructure/jobs/` + - `middlewares/` → `presentation/middlewares/` + - `views/` → `presentation/views/` + +### 阶段三:更新引用路径 +1. 更新 `main.js` 中的导入路径 +2. 更新中间件注册路径 +3. 更新服务间的依赖引用 +4. 更新路由自动发现配置 + +## 实现细节 + +### 模块内 MVC 组织 + +每个业务模块内部采用标准的 MVC 分层: + +```mermaid +graph LR + subgraph "auth 模块" + AC[AuthController] --> AS[AuthService] + AS --> UM[UserModel] + APC[AuthPageController] --> AS + end + + subgraph "共享层" + BC[BaseController] + BS[BaseService] + BM[BaseModel] + end + + AC -.-> BC + AS -.-> BS + UM -.-> BM +``` + +### 路径映射表 + +| 当前路径 | 新路径 | 说明 | +|---------|-------|------| +| `src/config/` | `src/app/config/` | 配置管理 | +| `src/global.js` | `src/app/bootstrap/app.js` | 应用实例 | +| `src/logger.js` | `src/app/bootstrap/logger.js` | 日志配置 | +| `src/controllers/Api/AuthController.js` | `src/modules/auth/controllers/AuthController.js` | 认证API控制器 | +| `src/controllers/Page/AuthPageController.js` | `src/modules/auth/controllers/AuthPageController.js` | 认证页面控制器 | +| `src/services/userService.js` | `src/modules/auth/services/AuthService.js` + `src/modules/user/services/UserService.js` | 拆分认证和用户服务 | +| `src/db/models/UserModel.js` | `src/modules/auth/models/UserModel.js` | 用户模型 | +| `src/middlewares/` | `src/presentation/middlewares/` | 中间件 | +| `src/views/` | `src/presentation/views/` | 视图模板 | +| `src/db/migrations/` | `src/infrastructure/database/migrations/` | 数据库迁移 | +| `src/jobs/` | `src/infrastructure/jobs/` | 定时任务 | + +### 导入路径更新 + +#### main.js 更新示例 +```javascript +// 原始导入 +import { app } from "./global" +import { logger } from "./logger.js" +import "./jobs/index.js" +import LoadMiddlewares from "./middlewares/install.js" + +// 更新后导入 +import { app } from "./app/bootstrap/app.js" +import { logger } from "./app/bootstrap/logger.js" +import "./infrastructure/jobs/index.js" +import LoadMiddlewares from "./presentation/middlewares/install.js" +``` + +### 自动路由发现适配 + +更新路由扫描配置以适配新的模块结构: + +```javascript +// 扫描路径配置 +const scanPaths = [ + 'src/modules/*/controllers/**/*.js', + 'src/modules/common/controllers/**/*.js' +]; +``` + +## 文件操作清单 + +### 需要移动的文件 +- [ ] `src/config/` → `src/app/config/` +- [ ] `src/global.js` → `src/app/bootstrap/app.js` +- [ ] `src/logger.js` → `src/app/bootstrap/logger.js` +- [ ] `src/db/` → `src/infrastructure/database/` +- [ ] `src/jobs/` → `src/infrastructure/jobs/` +- [ ] `src/middlewares/` → `src/presentation/middlewares/` +- [ ] `src/views/` → `src/presentation/views/` +- [ ] `src/utils/` → `src/shared/utils/` + +### 需要按模块重组的文件 +- [ ] 认证相关:`AuthController.js`, `AuthPageController.js`, `userService.js`(部分), `UserModel.js` +- [ ] 用户管理:`ProfileController.js`, `userService.js`(部分) +- [ ] 文章管理:`ArticleController.js`, `ArticleService.js`, `ArticleModel.js` +- [ ] 书签管理:`BookmarkService.js`, `BookmarkModel.js` +- [ ] 站点配置:`SiteConfigService.js`, `SiteConfigModel.js` +- [ ] 通用功能:`ApiController.js`, `StatusController.js`, `UploadController.js`, `JobController.js` + +### 需要更新导入的文件 +- [ ] `src/main.js` +- [ ] `src/presentation/middlewares/install.js` +- [ ] 各个控制器文件中的服务导入 +- [ ] 各个服务文件中的模型导入 + +## 预期效果 + +### 代码组织改善 +1. **业务内聚性**:相关的 controller、service、model 集中在同一模块 +2. **职责清晰**:每个模块负责特定的业务领域 +3. **依赖明确**:模块间依赖关系更加清晰可见 + +### 开发体验提升 +1. **文件查找**:按业务功能快速定位相关文件 +2. **代码维护**:修改某个功能时,相关文件集中在一个目录 +3. **团队协作**:不同开发者可以专注于不同的业务模块 + +### 架构扩展性 +1. **新增模块**:按照统一规范快速创建新的业务模块 +2. **功能拆分**:大模块可以进一步拆分成子模块 +3. **微服务准备**:为将来可能的微服务拆分做好准备 + +## 风险评估与注意事项 + +### 低风险操作 +- 目录创建和文件移动 +- 路径引用更新 +- 配置文件调整 + +### 需要谨慎的操作 +- 路由自动发现逻辑调整 +- 中间件注册顺序维护 +- 数据库连接管理迁移 + +### 测试验证点 +1. 应用启动正常 +2. 路由注册成功 +3. 数据库连接正常 +4. 中间件功能完整 +5. 各业务模块功能正常 + +本方案通过最小化改动实现目录结构优化,保持原有架构优势的同时提升代码组织质量,为项目的长期维护和扩展奠定良好基础。 \ No newline at end of file diff --git a/src/app/bootstrap/app.js b/src/app/bootstrap/app.js new file mode 100644 index 0000000..0d9b2ab --- /dev/null +++ b/src/app/bootstrap/app.js @@ -0,0 +1,27 @@ +/** + * 应用实例 + * + * 创建和配置 Koa 应用实例 + */ + +import Koa from "koa"; +import { logger } from "./logger.js"; +import { validateEnvironment } from "../../shared/utils/envValidator.js"; + +// 启动前验证环境变量 +if (!validateEnvironment()) { + logger.error("环境变量验证失败,应用退出"); + process.exit(1); +} + +const app = new Koa({ asyncLocalStorage: true }); + +app.keys = []; + +// SESSION_SECRET 已通过环境变量验证确保存在 +process.env.SESSION_SECRET.split(",").forEach(secret => { + app.keys.push(secret.trim()); +}); + +export { app }; +export default app; \ No newline at end of file diff --git a/src/app/bootstrap/index.js b/src/app/bootstrap/index.js new file mode 100644 index 0000000..25ee3da --- /dev/null +++ b/src/app/bootstrap/index.js @@ -0,0 +1,22 @@ +/** + * 应用引导索引文件 + * + * 统一导出应用引导相关模块 + */ + +import app from "./app.js"; +import { logger, jobLogger, errorLogger } from "./logger.js"; + +export { + app, + logger, + jobLogger, + errorLogger +}; + +export default { + app, + logger, + jobLogger, + errorLogger +}; \ No newline at end of file diff --git a/src/app/bootstrap/logger.js b/src/app/bootstrap/logger.js new file mode 100644 index 0000000..c0523f6 --- /dev/null +++ b/src/app/bootstrap/logger.js @@ -0,0 +1,68 @@ +/** + * 日志配置 + * + * 配置和导出日志记录器 + */ + +import log4js from "log4js"; + +// 日志目录可通过环境变量 LOG_DIR 配置,默认 logs +const LOG_DIR = process.env.LOG_DIR || "logs"; + +log4js.configure({ + appenders: { + all: { + type: "file", + filename: `${LOG_DIR}/all.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + error: { + type: "file", + filename: `${LOG_DIR}/error.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + jobs: { + type: "file", + filename: `${LOG_DIR}/jobs.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + console: { + type: "console", + layout: { + type: "pattern", + pattern: '\x1b[36m[%d{yyyy-MM-dd hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m', + }, + }, + }, + categories: { + jobs: { appenders: ["console", "jobs"], level: "info" }, + error: { appenders: ["console", "error"], level: "error" }, + default: { appenders: ["console", "all", "error"], level: "all" }, + }, +}); + +// 导出常用 logger 实例,便于直接引用 +export const logger = log4js.getLogger(); // default +export const jobLogger = log4js.getLogger('jobs'); +export const errorLogger = log4js.getLogger('error'); \ No newline at end of file diff --git a/src/app/config/database.js b/src/app/config/database.js new file mode 100644 index 0000000..ac3fcc8 --- /dev/null +++ b/src/app/config/database.js @@ -0,0 +1,55 @@ +/** + * 数据库配置文件 + * + * 提供数据库连接配置和相关设置 + */ + +const environment = process.env.NODE_ENV || "development"; + +// 数据库配置映射 +const databaseConfig = { + development: { + client: "sqlite3", + connection: { + filename: "./database/development.sqlite3" + }, + useNullAsDefault: true, + migrations: { + directory: "./src/infrastructure/database/migrations" + }, + seeds: { + directory: "./src/infrastructure/database/seeds" + } + }, + + test: { + client: "sqlite3", + connection: { + filename: ":memory:" + }, + useNullAsDefault: true, + migrations: { + directory: "./src/infrastructure/database/migrations" + }, + seeds: { + directory: "./src/infrastructure/database/seeds" + } + }, + + production: { + client: "sqlite3", + connection: { + filename: process.env.DATABASE_URL || "./database/production.sqlite3" + }, + useNullAsDefault: true, + migrations: { + directory: "./src/infrastructure/database/migrations" + }, + seeds: { + directory: "./src/infrastructure/database/seeds" + } + } +}; + +export default databaseConfig[environment]; +export { databaseConfig }; \ No newline at end of file diff --git a/src/app/config/index.js b/src/app/config/index.js new file mode 100644 index 0000000..2b0beb8 --- /dev/null +++ b/src/app/config/index.js @@ -0,0 +1,3 @@ +export default { + base: "/", +} diff --git a/src/app/providers/DatabaseProvider.js b/src/app/providers/DatabaseProvider.js new file mode 100644 index 0000000..7a7b953 --- /dev/null +++ b/src/app/providers/DatabaseProvider.js @@ -0,0 +1,216 @@ +/** + * 数据库服务提供者 + * + * 负责数据库连接管理、迁移管理和查询缓存 + */ + +import buildKnex from "knex"; +import databaseConfig from "../config/database.js"; + +// 简单内存缓存(支持 TTL 与按前缀清理) +const queryCache = new Map(); + +const getNow = () => Date.now(); + +const computeExpiresAt = (ttlMs) => { + if (!ttlMs || ttlMs <= 0) return null; + return getNow() + ttlMs; +}; + +const isExpired = (entry) => { + if (!entry) return true; + if (entry.expiresAt == null) return false; + return entry.expiresAt <= getNow(); +}; + +const getCacheKeyForBuilder = (builder) => { + if (builder._customCacheKey) return String(builder._customCacheKey); + return builder.toString(); +}; + +// 全局工具,便于在 QL 外部操作缓存 +export const DbQueryCache = { + get(key) { + const entry = queryCache.get(String(key)); + if (!entry) return undefined; + if (isExpired(entry)) { + queryCache.delete(String(key)); + return undefined; + } + return entry.value; + }, + set(key, value, ttlMs) { + const expiresAt = computeExpiresAt(ttlMs); + queryCache.set(String(key), { value, expiresAt }); + return value; + }, + has(key) { + const entry = queryCache.get(String(key)); + return !!entry && !isExpired(entry); + }, + delete(key) { + return queryCache.delete(String(key)); + }, + clear() { + queryCache.clear(); + }, + clearByPrefix(prefix) { + const p = String(prefix); + for (const k of queryCache.keys()) { + if (k.startsWith(p)) queryCache.delete(k); + } + }, + stats() { + let valid = 0; + let expired = 0; + for (const [k, entry] of queryCache.entries()) { + if (isExpired(entry)) expired++; + else valid++; + } + return { size: queryCache.size, valid, expired }; + } +}; + +class DatabaseProvider { + constructor() { + this.db = null; + this._isExtended = false; + } + + /** + * 注册数据库连接 + */ + register() { + if (this.db) { + return this.db; + } + + // 创建 Knex 实例 + this.db = buildKnex(databaseConfig); + + // 扩展 QueryBuilder 缓存功能(只在第一次初始化时扩展) + if (!this._isExtended) { + this._extendQueryBuilder(); + this._isExtended = true; + } + + return this.db; + } + + /** + * 扩展 QueryBuilder 的缓存功能 + */ + _extendQueryBuilder() { + // 1) cache(ttlMs?): 读取缓存,不存在则执行并写入 + buildKnex.QueryBuilder.extend("cache", async function (ttlMs) { + const key = getCacheKeyForBuilder(this); + const entry = queryCache.get(key); + if (entry && !isExpired(entry)) { + return entry.value; + } + const data = await this; + queryCache.set(key, { value: data, expiresAt: computeExpiresAt(ttlMs) }); + return data; + }); + + // 2) cacheAs(customKey): 设置自定义 key + buildKnex.QueryBuilder.extend("cacheAs", function (customKey) { + this._customCacheKey = String(customKey); + return this; + }); + + // 3) cacheSet(value, ttlMs?): 手动设置当前查询 key 的缓存 + buildKnex.QueryBuilder.extend("cacheSet", function (value, ttlMs) { + const key = getCacheKeyForBuilder(this); + queryCache.set(key, { value, expiresAt: computeExpiresAt(ttlMs) }); + return value; + }); + + // 4) cacheGet(): 仅从缓存读取当前查询 key 的值 + buildKnex.QueryBuilder.extend("cacheGet", function () { + const key = getCacheKeyForBuilder(this); + const entry = queryCache.get(key); + if (!entry || isExpired(entry)) return undefined; + return entry.value; + }); + + // 5) cacheInvalidate(): 使当前查询 key 的缓存失效 + buildKnex.QueryBuilder.extend("cacheInvalidate", function () { + const key = getCacheKeyForBuilder(this); + queryCache.delete(key); + return this; + }); + + // 6) cacheInvalidateByPrefix(prefix): 按前缀清理 + buildKnex.QueryBuilder.extend("cacheInvalidateByPrefix", function (prefix) { + const p = String(prefix); + for (const k of queryCache.keys()) { + if (k.startsWith(p)) queryCache.delete(k); + } + return this; + }); + } + + /** + * 运行数据库迁移 + */ + async runMigrations() { + if (!this.db) { + throw new Error("Database not initialized. Call register() first."); + } + + try { + const [batch, log] = await this.db.migrate.latest(); + if (log.length === 0) { + console.log("Database is already up to date"); + } else { + console.log(`Migrated ${log.length} files:`, log); + } + return { batch, log }; + } catch (error) { + console.error("Migration failed:", error); + throw error; + } + } + + /** + * 运行种子数据 + */ + async runSeeds() { + if (!this.db) { + throw new Error("Database not initialized. Call register() first."); + } + + try { + const [log] = await this.db.seed.run(); + console.log("Seeds completed:", log); + return log; + } catch (error) { + console.error("Seeding failed:", error); + throw error; + } + } + + /** + * 获取数据库实例 + */ + getDatabase() { + return this.db; + } + + /** + * 关闭数据库连接 + */ + async close() { + if (this.db) { + await this.db.destroy(); + this.db = null; + } + } +} + +// 导出单例实例 +const databaseProvider = new DatabaseProvider(); + +export default databaseProvider; +export { DatabaseProvider }; \ No newline at end of file diff --git a/src/app/providers/index.js b/src/app/providers/index.js new file mode 100644 index 0000000..15ad712 --- /dev/null +++ b/src/app/providers/index.js @@ -0,0 +1,15 @@ +/** + * 服务提供者索引文件 + * + * 统一导出所有服务提供者 + */ + +import DatabaseProvider from "./DatabaseProvider.js"; + +export { + DatabaseProvider +}; + +export default { + DatabaseProvider +}; \ No newline at end of file diff --git a/src/config/index.js b/src/config/index.js deleted file mode 100644 index 2b0beb8..0000000 --- a/src/config/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - base: "/", -} diff --git a/src/controllers/Api/ApiController.js b/src/controllers/Api/ApiController.js deleted file mode 100644 index 602e56e..0000000 --- a/src/controllers/Api/ApiController.js +++ /dev/null @@ -1,58 +0,0 @@ -import { R } from "utils/helper.js" -import Router from "utils/router.js" - -class AuthController { - constructor() {} - - /** - * 通用请求函数:依次请求网址数组,返回第一个成功的响应及其类型 - * @param {string[]} urls - * @returns {Promise<{type: string, data: any}>} - */ - async fetchFirstSuccess(urls) { - for (const url of urls) { - try { - const res = await fetch(url, { method: "get", mode: "cors", redirect: "follow" }) - if (!res.ok) continue - const contentType = res.headers.get("content-type") || "" - let data, type - if (contentType.includes("application/json")) { - data = await res.json() - type = "json" - } else if (contentType.includes("text/")) { - data = await res.text() - type = "text" - } else { - data = await res.blob() - type = "blob" - } - return { type, data } - } catch (e) { - // ignore and try next url - } - } - throw new Error("All requests failed") - } - - async random(ctx) { - const { type, data } = await this.fetchFirstSuccess(["https://api.miaomc.cn/image/get"]) - if (type === "blob") { - ctx.set("Content-Type", "image/jpeg") - ctx.body = data - } else { - R.ResponseJSON(R.ERROR, "Failed to fetch image") - } - } - - /** - * 路由注册 - */ - static createRoutes() { - const controller = new AuthController() - const router = new Router({ prefix: "/api/pics" }) - router.get("/random", controller.random.bind(controller), { auth: false }) - return router - } -} - -export default AuthController diff --git a/src/controllers/Api/AuthController.js b/src/controllers/Api/AuthController.js deleted file mode 100644 index 4c4e5cd..0000000 --- a/src/controllers/Api/AuthController.js +++ /dev/null @@ -1,45 +0,0 @@ -import UserService from "services/userService.js" -import { R } from "utils/helper.js" -import Router from "utils/router.js" - -class AuthController { - constructor() { - this.userService = new UserService() - } - - async hello(ctx) { - R.ResponseJSON(R.SUCCESS,"Hello World") - } - - async getUser(ctx) { - const user = await this.userService.getUserById(ctx.params.id) - R.ResponseJSON(R.SUCCESS,user) - } - - async register(ctx) { - const { username, email, password } = ctx.request.body - const user = await this.userService.register({ username, email, password }) - R.ResponseJSON(R.SUCCESS,user) - } - - async login(ctx) { - const { username, email, password } = ctx.request.body - const result = await this.userService.login({ username, email, password }) - R.ResponseJSON(R.SUCCESS,result) - } - - /** - * 路由注册 - */ - static createRoutes() { - const controller = new AuthController() - const router = new Router({ prefix: "/api" }) - router.get("/hello", controller.hello.bind(controller), { auth: false }) - router.get("/user/:id", controller.getUser.bind(controller)) - router.post("/register", controller.register.bind(controller)) - router.post("/login", controller.login.bind(controller)) - return router - } -} - -export default AuthController diff --git a/src/controllers/Api/JobController.js b/src/controllers/Api/JobController.js deleted file mode 100644 index 719fddf..0000000 --- a/src/controllers/Api/JobController.js +++ /dev/null @@ -1,46 +0,0 @@ -// Job Controller 示例:如何调用 service 层动态控制和查询定时任务 -import JobService from "services/JobService.js" -import { R } from "utils/helper.js" -import Router from "utils/router.js" - -class JobController { - constructor() { - this.jobService = new JobService() - } - - async list(ctx) { - const data = this.jobService.listJobs() - R.ResponseJSON(R.SUCCESS,data) - } - - async start(ctx) { - const { id } = ctx.params - this.jobService.startJob(id) - R.ResponseJSON(R.SUCCESS,null, `${id} 任务已启动`) - } - - async stop(ctx) { - const { id } = ctx.params - this.jobService.stopJob(id) - R.ResponseJSON(R.SUCCESS,null, `${id} 任务已停止`) - } - - async updateCron(ctx) { - const { id } = ctx.params - const { cronTime } = ctx.request.body - this.jobService.updateJobCron(id, cronTime) - R.ResponseJSON(R.SUCCESS,null, `${id} 任务频率已修改`) - } - - static createRoutes() { - const controller = new JobController() - const router = new Router({ prefix: "/api/jobs" }) - router.get("/", controller.list.bind(controller)) - router.post("/start/:id", controller.start.bind(controller)) - router.post("/stop/:id", controller.stop.bind(controller)) - router.post("/update/:id", controller.updateCron.bind(controller)) - return router - } -} - -export default JobController diff --git a/src/controllers/Api/StatusController.js b/src/controllers/Api/StatusController.js deleted file mode 100644 index d9cef1c..0000000 --- a/src/controllers/Api/StatusController.js +++ /dev/null @@ -1,20 +0,0 @@ -import Router from "utils/router.js" - -class StatusController { - async status(ctx) { - ctx.body = "OK" - } - - static createRoutes() { - const controller = new StatusController() - const v1 = new Router({ prefix: "/api/v1" }) - v1.use((ctx, next) => { - ctx.set("X-API-Version", "v1") - return next() - }) - v1.get("/status", controller.status.bind(controller)) - return v1 - } -} - -export default StatusController diff --git a/src/controllers/Page/ArticleController.js b/src/controllers/Page/ArticleController.js deleted file mode 100644 index 8809814..0000000 --- a/src/controllers/Page/ArticleController.js +++ /dev/null @@ -1,130 +0,0 @@ -import { ArticleModel } from "../../db/models/ArticleModel.js" -import Router from "utils/router.js" -import { marked } from "marked" - -class ArticleController { - async index(ctx) { - const { page = 1, view = 'grid' } = ctx.query - const limit = 12 // 每页显示的文章数量 - const offset = (page - 1) * limit - - // 获取文章总数 - const total = await ArticleModel.getPublishedArticleCount() - const totalPages = Math.ceil(total / limit) - - // 获取分页文章 - const articles = await ArticleModel.findPublished(offset, limit) - - // 获取所有分类和标签 - const categories = await ArticleModel.getArticleCountByCategory() - const allArticles = await ArticleModel.findPublished() - const tags = new Set() - allArticles.forEach(article => { - if (article.tags) { - article.tags.split(',').forEach(tag => { - tags.add(tag.trim()) - }) - } - }) - - return ctx.render("page/articles/index", { - articles, - categories: categories.map(c => c.category), - tags: Array.from(tags), - currentPage: parseInt(page), - totalPages, - view, - title: "文章列表", - }, { - includeUser: true, - includeSite: true, - }) - } - - async show(ctx) { - const { slug } = ctx.params - console.log(slug); - - const article = await ArticleModel.findBySlug(slug) - - if (!article) { - ctx.throw(404, "文章不存在") - } - - // 增加阅读次数 - await ArticleModel.incrementViewCount(article.id) - - // 将文章内容解析为HTML - article.content = marked(article.content || '') - - // 获取相关文章 - const relatedArticles = await ArticleModel.getRelatedArticles(article.id) - - return ctx.render("page/articles/article", { - article, - relatedArticles, - title: article.title, - }, { - includeUser: true, - }) - } - - async byCategory(ctx) { - const { category } = ctx.params - const articles = await ArticleModel.findByCategory(category) - - return ctx.render("page/articles/category", { - articles, - category, - title: `${category} - 分类文章`, - }, { - includeUser: true, - }) - } - - async byTag(ctx) { - const { tag } = ctx.params - const articles = await ArticleModel.findByTags(tag) - - return ctx.render("page/articles/tag", { - articles, - tag, - title: `${tag} - 标签文章`, - }, { - includeUser: true, - }) - } - - async search(ctx) { - const { q } = ctx.query - - if(!q) { - return ctx.set('hx-redirect', '/articles') - } - - const articles = await ArticleModel.searchByKeyword(q) - - return ctx.render("page/articles/search", { - articles, - keyword: q, - title: `搜索:${q}`, - }, { - includeUser: true, - }) - } - - static createRoutes() { - const controller = new ArticleController() - const router = new Router({ auth: true, prefix: "/articles" }) - router.get("", controller.index, { auth: false }) // 允许未登录访问 - router.get("/", controller.index, { auth: false }) // 允许未登录访问 - router.get("/search", controller.search, { auth: false }) - router.get("/category/:category", controller.byCategory) - router.get("/tag/:tag", controller.byTag) - router.get("/:slug", controller.show) - return router - } -} - -export default ArticleController -export { ArticleController } diff --git a/src/controllers/Page/AuthPageController.js b/src/controllers/Page/AuthPageController.js deleted file mode 100644 index 1fd68b0..0000000 --- a/src/controllers/Page/AuthPageController.js +++ /dev/null @@ -1,136 +0,0 @@ -import Router from "utils/router.js" -import UserService from "services/userService.js" -import svgCaptcha from "svg-captcha" -import CommonError from "@/utils/error/CommonError" -import { logger } from "@/logger.js" - -/** - * 认证相关页面控制器 - * 负责处理登录、注册、验证码、登出等认证相关功能 - */ -class AuthPageController { - constructor() { - this.userService = new UserService() - } - - // 未授权报错页 - async indexNoAuth(ctx) { - return await ctx.render("page/auth/no-auth", {}) - } - - // 登录页 - async loginGet(ctx) { - if (ctx.session.user) { - ctx.status = 200 - ctx.redirect("/?msg=用户已登录") - return - } - return await ctx.render("page/login/index", { site_title: "登录" }) - } - - // 处理登录请求 - async loginPost(ctx) { - const { username, email, password } = ctx.request.body - const result = await this.userService.login({ username, email, password }) - ctx.session.user = result.user - ctx.body = { success: true, message: "登录成功" } - } - - // 获取验证码 - async captchaGet(ctx) { - var captcha = svgCaptcha.create({ - size: 4, // 个数 - width: 100, // 宽 - height: 30, // 高 - fontSize: 38, // 字体大小 - color: true, // 字体颜色是否多变 - noise: 4, // 干扰线几条 - }) - // 记录验证码信息(文本+过期时间) - // 这里设置5分钟后过期 - const expireTime = Date.now() + 5 * 60 * 1000 - ctx.session.captcha = { - text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证 - expireTime: expireTime, - } - ctx.type = "image/svg+xml" - ctx.body = captcha.data - } - - // 注册页 - async registerGet(ctx) { - if (ctx.session.user) { - return ctx.redirect("/?msg=用户已登录") - } - return await ctx.render("page/register/index", { site_title: "注册" }) - } - - // 处理注册请求 - async registerPost(ctx) { - const { username, password, code } = ctx.request.body - - // 检查Session中是否存在验证码 - if (!ctx.session.captcha) { - throw new CommonError("验证码不存在,请重新获取") - } - - const { text, expireTime } = ctx.session.captcha - - // 检查是否过期 - if (Date.now() > expireTime) { - // 过期后清除Session中的验证码 - delete ctx.session.captcha - throw new CommonError("验证码已过期,请重新获取") - } - - if (!code) { - throw new CommonError("请输入验证码") - } - - if (code.toLowerCase() !== text) { - throw new CommonError("验证码错误") - } - - delete ctx.session.captcha - - await this.userService.register({ username, name: username, password, role: "user" }) - return ctx.redirect("/login") - } - - // 退出登录 - async logout(ctx) { - ctx.status = 200 - delete ctx.session.user - ctx.set("hx-redirect", "/") - } - - /** - * 创建认证相关路由 - * @returns {Router} 路由实例 - */ - static createRoutes() { - const controller = new AuthPageController() - const router = new Router({ auth: "try" }) - - // 未授权报错页 - router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) - - // 登录相关 - router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) - router.post("/login", controller.loginPost.bind(controller), { auth: false }) - - // 注册相关 - router.get("/register", controller.registerGet.bind(controller), { auth: "try" }) - router.post("/register", controller.registerPost.bind(controller), { auth: false }) - - // 验证码 - router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) - - // 登出 - router.post("/logout", controller.logout.bind(controller), { auth: true }) - - return router - } -} - -export default AuthPageController \ No newline at end of file diff --git a/src/controllers/Page/BasePageController.js b/src/controllers/Page/BasePageController.js deleted file mode 100644 index 411a431..0000000 --- a/src/controllers/Page/BasePageController.js +++ /dev/null @@ -1,102 +0,0 @@ -import Router from "utils/router.js" -import ArticleService from "services/ArticleService.js" -import { logger } from "@/logger.js" - -/** - * 基础页面控制器 - * 负责处理首页、静态页面、联系表单等基础功能 - */ -class BasePageController { - constructor() { - this.articleService = new ArticleService() - } - - // 首页 - async indexGet(ctx) { - const blogs = await this.articleService.getPublishedArticles() - return await ctx.render( - "page/index/index", - { - apiList: [ - { - name: "随机图片", - desc: "随机图片,点击查看。
右键可复制链接", - url: "https://pic.xieyaxin.top/random.php", - }, - ], - blogs: blogs.slice(0, 4), - }, - { includeSite: true, includeUser: true } - ) - } - - // 处理联系表单提交 - async contactPost(ctx) { - const { name, email, subject, message } = ctx.request.body - - // 简单的表单验证 - if (!name || !email || !subject || !message) { - ctx.status = 400 - ctx.body = { success: false, message: "请填写所有必填字段" } - return - } - - // 这里可以添加邮件发送逻辑或数据库存储逻辑 - // 目前只是简单的成功响应 - logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`) - - ctx.body = { - success: true, - message: "感谢您的留言,我们会尽快回复您!", - } - } - - /** - * 通用页面渲染方法 - * @param {string} name - 模板名称 - * @param {Object} data - 页面数据 - * @returns {Function} 页面渲染函数 - */ - pageGet(name, data) { - return async ctx => { - return await ctx.render( - name, - { - ...(data || {}), - }, - { includeSite: true, includeUser: true } - ) - } - } - - /** - * 创建基础页面相关路由 - * @returns {Router} 路由实例 - */ - static createRoutes() { - const controller = new BasePageController() - const router = new Router({ auth: "try" }) - - // 首页 - router.get("/", controller.indexGet.bind(controller), { auth: false }) - - // 静态页面 - router.get("/about", controller.pageGet("page/about/index"), { auth: false }) - router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false }) - router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false }) - router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false }) - router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false }) - router.get("/help", controller.pageGet("page/extra/help"), { auth: false }) - router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false }) - - // 需要登录的页面 - router.get("/notice", controller.pageGet("page/notice/index"), { auth: true }) - - // 联系表单处理 - router.post("/contact", controller.contactPost.bind(controller), { auth: false }) - - return router - } -} - -export default BasePageController \ No newline at end of file diff --git a/src/controllers/Page/ProfileController.js b/src/controllers/Page/ProfileController.js deleted file mode 100644 index 3a3678c..0000000 --- a/src/controllers/Page/ProfileController.js +++ /dev/null @@ -1,228 +0,0 @@ -import Router from "utils/router.js" -import UserService from "services/userService.js" -import formidable from "formidable" -import fs from "fs/promises" -import path from "path" -import { fileURLToPath } from "url" -import CommonError from "@/utils/error/CommonError" -import { logger } from "@/logger.js" -import imageThumbnail from "image-thumbnail" - -/** - * 用户资料控制器 - * 负责处理用户资料管理、密码修改、头像上传等功能 - */ -class ProfileController { - constructor() { - this.userService = new UserService() - } - - // 获取用户资料 - async profileGet(ctx) { - if (!ctx.session.user) { - return ctx.redirect("/login") - } - - try { - const user = await this.userService.getUserById(ctx.session.user.id) - return await ctx.render( - "page/profile/index", - { - user, - site_title: "用户资料", - }, - { includeSite: true, includeUser: true } - ) - } catch (error) { - logger.error(`获取用户资料失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: "获取用户资料失败" } - } - } - - // 更新用户资料 - async profileUpdate(ctx) { - if (!ctx.session.user) { - ctx.status = 401 - ctx.body = { success: false, message: "未登录" } - return - } - - try { - const { username, email, name, bio, avatar } = ctx.request.body - - // 验证必填字段 - if (!username) { - ctx.status = 400 - ctx.body = { success: false, message: "用户名不能为空" } - return - } - - const updateData = { username, email, name, bio, avatar } - - // 移除空值 - Object.keys(updateData).forEach(key => { - if (updateData[key] === undefined || updateData[key] === null || updateData[key] === "") { - delete updateData[key] - } - }) - - const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData) - - // 更新session中的用户信息 - ctx.session.user = { ...ctx.session.user, ...updatedUser } - - ctx.body = { - success: true, - message: "资料更新成功", - user: updatedUser, - } - } catch (error) { - logger.error(`更新用户资料失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: error.message || "更新用户资料失败" } - } - } - - // 修改密码 - async changePassword(ctx) { - if (!ctx.session.user) { - ctx.status = 401 - ctx.body = { success: false, message: "未登录" } - return - } - - try { - const { oldPassword, newPassword, confirmPassword } = ctx.request.body - - if (!oldPassword || !newPassword || !confirmPassword) { - ctx.status = 400 - ctx.body = { success: false, message: "请填写所有密码字段" } - return - } - - if (newPassword !== confirmPassword) { - ctx.status = 400 - ctx.body = { success: false, message: "新密码与确认密码不匹配" } - return - } - - if (newPassword.length < 6) { - ctx.status = 400 - ctx.body = { success: false, message: "新密码长度不能少于6位" } - return - } - - await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword) - - ctx.body = { - success: true, - message: "密码修改成功", - } - } catch (error) { - logger.error(`修改密码失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: error.message || "修改密码失败" } - } - } - - // 上传头像(multipart/form-data) - async uploadAvatar(ctx) { - try { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const publicDir = path.resolve(__dirname, "../../../public") - const avatarsDir = path.resolve(publicDir, "uploads/avatars") - - // 确保目录存在 - await fs.mkdir(avatarsDir, { recursive: true }) - - const form = formidable({ - multiples: false, - maxFileSize: 5 * 1024 * 1024, // 5MB - filter: ({ mimetype }) => { - return !!mimetype && /^(image\/jpeg|image\/png|image\/webp|image\/gif)$/.test(mimetype) - }, - uploadDir: avatarsDir, - keepExtensions: true, - }) - - const { files } = await new Promise((resolve, reject) => { - form.parse(ctx.req, (err, fields, files) => { - if (err) return reject(err) - resolve({ fields, files }) - }) - }) - - const file = files.avatar || files.file || files.image - const picked = Array.isArray(file) ? file[0] : file - if (!picked) { - ctx.status = 400 - ctx.body = { success: false, message: "未选择文件或字段名应为 avatar" } - return - } - - // formidable v2 的文件对象 - const oldPath = picked.filepath || picked.path - const result = { url: "", thumb: "" } - const ext = path.extname(picked.originalFilename || picked.newFilename || "") || path.extname(oldPath || "") || ".jpg" - const safeExt = [".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(ext.toLowerCase()) ? ext : ".jpg" - const filename = `${ctx.session.user.id}-${Date.now()}/raw${safeExt}` - const destPath = path.join(avatarsDir, filename) - - // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名 - if (oldPath && oldPath !== destPath) { - await fs.mkdir(path.parse(destPath).dir, { recursive: true }) - await fs.rename(oldPath, destPath) - try { - const thumbnail = await imageThumbnail(destPath) - fs.writeFile(destPath.replace(/raw\./, "thumb."), thumbnail) - } catch (err) { - console.error(err) - } - } - - const url = `/uploads/avatars/${filename}` - result.url = url - result.thumb = url.replace(/raw\./, "thumb.") - const updatedUser = await this.userService.updateUser(ctx.session.user.id, { avatar: url }) - ctx.session.user = { ...ctx.session.user, ...updatedUser } - - ctx.body = { - success: true, - message: "头像上传成功", - url, - thumb: result.thumb, - user: updatedUser, - } - } catch (error) { - logger.error(`上传头像失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: error.message || "上传头像失败" } - } - } - - /** - * 创建用户资料相关路由 - * @returns {Router} 路由实例 - */ - static createRoutes() { - const controller = new ProfileController() - const router = new Router({ auth: "try" }) - - // 用户资料页面 - router.get("/profile", controller.profileGet.bind(controller), { auth: true }) - - // 用户资料更新 - router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true }) - - // 密码修改 - router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true }) - - // 头像上传 - router.post("/profile/upload-avatar", controller.uploadAvatar.bind(controller), { auth: true }) - - return router - } -} - -export default ProfileController \ No newline at end of file diff --git a/src/controllers/Page/UploadController.js b/src/controllers/Page/UploadController.js deleted file mode 100644 index e172b7f..0000000 --- a/src/controllers/Page/UploadController.js +++ /dev/null @@ -1,200 +0,0 @@ -import Router from "utils/router.js" -import formidable from "formidable" -import fs from "fs/promises" -import path from "path" -import { fileURLToPath } from "url" -import { logger } from "@/logger.js" -import { R } from "@/utils/helper" - -/** - * 文件上传控制器 - * 负责处理通用文件上传功能 - */ -class UploadController { - constructor() { - // 初始化上传配置 - this.initConfig() - } - - /** - * 初始化上传配置 - */ - initConfig() { - // 默认支持的文件类型配置 - this.defaultTypeList = [ - { mime: "image/jpeg", ext: ".jpg" }, - { mime: "image/png", ext: ".png" }, - { mime: "image/webp", ext: ".webp" }, - { mime: "image/gif", ext: ".gif" }, - { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx - { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls - { mime: "application/msword", ext: ".doc" }, // .doc - { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx - ] - - this.fallbackExt = ".bin" - this.maxFileSize = 10 * 1024 * 1024 // 10MB - } - - /** - * 获取允许的文件类型 - * @param {Object} ctx - Koa上下文 - * @returns {Array} 允许的文件类型列表 - */ - getAllowedTypes(ctx) { - let typeList = this.defaultTypeList - - // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型) - if (ctx.query.allowedTypes) { - const allowed = ctx.query.allowedTypes - .split(",") - .map(t => t.trim()) - .filter(Boolean) - typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime)) - } - - return typeList - } - - /** - * 获取上传目录路径 - * @returns {string} 上传目录路径 - */ - getUploadDir() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const publicDir = path.resolve(__dirname, "../../../public") - return path.resolve(publicDir, "uploads/files") - } - - /** - * 确保上传目录存在 - * @param {string} dir - 目录路径 - */ - async ensureUploadDir(dir) { - await fs.mkdir(dir, { recursive: true }) - } - - /** - * 生成安全的文件名 - * @param {Object} ctx - Koa上下文 - * @param {string} ext - 文件扩展名 - * @returns {string} 生成的文件名 - */ - generateFileName(ctx, ext) { - return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` - } - - /** - * 获取文件扩展名 - * @param {Object} file - 文件对象 - * @param {Array} typeList - 类型列表 - * @returns {string} 文件扩展名 - */ - getFileExtension(file, typeList) { - // 优先用mimetype判断扩展名 - let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext - if (!ext) { - // 回退到原始文件名的扩展名 - ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt - } - return ext - } - - /** - * 处理单个文件上传 - * @param {Object} file - 文件对象 - * @param {Object} ctx - Koa上下文 - * @param {string} uploadsDir - 上传目录 - * @param {Array} typeList - 类型列表 - * @returns {string} 文件URL - */ - async processFile(file, ctx, uploadsDir, typeList) { - if (!file) return null - - const oldPath = file.filepath || file.path - const ext = this.getFileExtension(file, typeList) - const filename = this.generateFileName(ctx, ext) - const destPath = path.join(uploadsDir, filename) - - // 移动文件到目标位置 - if (oldPath && oldPath !== destPath) { - await fs.rename(oldPath, destPath) - } - - // 返回相对于public的URL路径 - return `/uploads/files/${filename}` - } - - // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组) - async upload(ctx) { - try { - const uploadsDir = this.getUploadDir() - await this.ensureUploadDir(uploadsDir) - - const typeList = this.getAllowedTypes(ctx) - const allowedTypes = typeList.map(item => item.mime) - - const form = formidable({ - multiples: true, // 支持多文件 - maxFileSize: this.maxFileSize, - filter: ({ mimetype }) => { - return !!mimetype && allowedTypes.includes(mimetype) - }, - uploadDir: uploadsDir, - keepExtensions: true, - }) - - const { files } = await new Promise((resolve, reject) => { - form.parse(ctx.req, (err, fields, files) => { - if (err) return reject(err) - resolve({ fields, files }) - }) - }) - - let fileList = files.file - if (!fileList) { - return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file") - } - - // 统一为数组 - if (!Array.isArray(fileList)) { - fileList = [fileList] - } - - // 处理所有文件 - const urls = [] - for (const file of fileList) { - const url = await this.processFile(file, ctx, uploadsDir, typeList) - if (url) { - urls.push(url) - } - } - - ctx.body = { - success: true, - message: "上传成功", - urls, - } - } catch (error) { - logger.error(`上传失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: error.message || "上传失败" } - } - } - - /** - * 创建文件上传相关路由 - * @returns {Router} 路由实例 - */ - static createRoutes() { - const controller = new UploadController() - const router = new Router({ auth: "try" }) - - // 通用文件上传 - router.post("/upload", controller.upload.bind(controller), { auth: true }) - - return router - } -} - -export default UploadController \ No newline at end of file diff --git a/src/controllers/Page/_Demo/HtmxController.js b/src/controllers/Page/_Demo/HtmxController.js deleted file mode 100644 index 9908a22..0000000 --- a/src/controllers/Page/_Demo/HtmxController.js +++ /dev/null @@ -1,63 +0,0 @@ -import Router from "utils/router.js" - -class HtmxController { - async index(ctx) { - return await ctx.render("index", { name: "bluescurry" }) - } - - page(name, data) { - return async ctx => { - return await ctx.render(name, data) - } - } - - static createRoutes() { - const controller = new HtmxController() - const router = new Router({ auth: "try" }) - router.get("/htmx/timeline", async ctx => { - return await ctx.render("htmx/timeline", { - timeLine: [ - { - icon: "第一份工作", - title: "???", - desc: `做游戏的。`, - }, - { - icon: "大学毕业", - title: "2014年09月", - desc: `我从江西师范大学毕业, - 获得了软件工程(虚拟现实与技术)专业的学士学位。`, - }, - { - icon: "高中", - title: "???", - desc: `宜春中学`, - }, - { - icon: "初中", - title: "???", - desc: `宜春实验中学`, - }, - { - icon: "小学(4-6年级)", - title: "???", - desc: `宜春二小`, - }, - { - icon: "小学(1-3年级)", - title: "???", - desc: `丰城市泉港镇小学`, - }, - { - icon: "出生", - title: "1996年06月", - desc: `我出生于江西省丰城市泉港镇`, - }, - ], - }) - }) - return router - } -} - -export default HtmxController diff --git a/src/db/docs/ArticleModel.md b/src/db/docs/ArticleModel.md deleted file mode 100644 index c7e3d93..0000000 --- a/src/db/docs/ArticleModel.md +++ /dev/null @@ -1,190 +0,0 @@ -# 数据库模型文档 - -## ArticleModel - -ArticleModel 是一个功能完整的文章管理模型,提供了丰富的CRUD操作和查询方法。 - -### 主要特性 - -- ✅ 完整的CRUD操作 -- ✅ 文章状态管理(草稿、已发布、已归档) -- ✅ 自动生成slug、摘要和阅读时间 -- ✅ 标签和分类管理 -- ✅ SEO优化支持 -- ✅ 浏览量统计 -- ✅ 相关文章推荐 -- ✅ 全文搜索功能 - -### 数据库字段 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | integer | 主键,自增 | -| title | string | 文章标题(必填) | -| content | text | 文章内容(必填) | -| author | string | 作者 | -| category | string | 分类 | -| tags | string | 标签(逗号分隔) | -| keywords | string | SEO关键词 | -| description | string | 文章描述 | -| status | string | 状态:draft/published/archived | -| published_at | timestamp | 发布时间 | -| view_count | integer | 浏览量 | -| featured_image | string | 特色图片 | -| excerpt | text | 文章摘要 | -| reading_time | integer | 阅读时间(分钟) | -| meta_title | string | SEO标题 | -| meta_description | text | SEO描述 | -| slug | string | URL友好的标识符 | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | - -### 基本用法 - -```javascript -import { ArticleModel } from '../models/ArticleModel.js' - -// 创建文章 -const article = await ArticleModel.create({ - title: "我的第一篇文章", - content: "这是文章内容...", - author: "张三", - category: "技术", - tags: "JavaScript, Node.js, 教程" -}) - -// 查找所有已发布的文章 -const publishedArticles = await ArticleModel.findPublished() - -// 根据ID查找文章 -const article = await ArticleModel.findById(1) - -// 更新文章 -await ArticleModel.update(1, { - title: "更新后的标题", - content: "更新后的内容" -}) - -// 发布文章 -await ArticleModel.publish(1) - -// 删除文章 -await ArticleModel.delete(1) -``` - -### 查询方法 - -#### 基础查询 -- `findAll()` - 查找所有文章 -- `findById(id)` - 根据ID查找文章 -- `findBySlug(slug)` - 根据slug查找文章 -- `findPublished()` - 查找所有已发布的文章 -- `findDrafts()` - 查找所有草稿文章 - -#### 分类查询 -- `findByAuthor(author)` - 根据作者查找文章 -- `findByCategory(category)` - 根据分类查找文章 -- `findByTags(tags)` - 根据标签查找文章 - -#### 搜索功能 -- `searchByKeyword(keyword)` - 关键词搜索(标题、内容、关键词、描述、摘要) - -#### 统计功能 -- `getArticleCount()` - 获取文章总数 -- `getPublishedArticleCount()` - 获取已发布文章数量 -- `getArticleCountByCategory()` - 按分类统计文章数量 -- `getArticleCountByStatus()` - 按状态统计文章数量 - -#### 推荐功能 -- `getRecentArticles(limit)` - 获取最新文章 -- `getPopularArticles(limit)` - 获取热门文章 -- `getFeaturedArticles(limit)` - 获取特色文章 -- `getRelatedArticles(articleId, limit)` - 获取相关文章 - -#### 高级查询 -- `findByDateRange(startDate, endDate)` - 按日期范围查找文章 -- `incrementViewCount(id)` - 增加浏览量 - -### 状态管理 - -文章支持三种状态: -- `draft` - 草稿状态 -- `published` - 已发布状态 -- `archived` - 已归档状态 - -```javascript -// 发布文章 -await ArticleModel.publish(articleId) - -// 取消发布 -await ArticleModel.unpublish(articleId) -``` - -### 自动功能 - -#### 自动生成slug -如果未提供slug,系统会自动根据标题生成: -```javascript -// 标题: "我的第一篇文章" -// 自动生成slug: "我的第一篇文章" -``` - -#### 自动计算阅读时间 -基于内容长度自动计算阅读时间(假设每分钟200个单词) - -#### 自动生成摘要 -如果未提供摘要,系统会自动从内容中提取前150个字符 - -### 标签管理 - -标签支持逗号分隔的格式,系统会自动处理: -```javascript -// 输入: "JavaScript, Node.js, 教程" -// 存储: "JavaScript, Node.js, 教程" -// 查询: 支持模糊匹配 -``` - -### SEO优化 - -支持完整的SEO字段: -- `meta_title` - 页面标题 -- `meta_description` - 页面描述 -- `keywords` - 关键词 -- `slug` - URL友好的标识符 - -### 错误处理 - -所有方法都包含适当的错误处理: -```javascript -try { - const article = await ArticleModel.create({ - title: "", // 空标题会抛出错误 - content: "内容" - }) -} catch (error) { - console.error("创建文章失败:", error.message) -} -``` - -### 性能优化 - -- 所有查询都包含适当的索引 -- 支持分页查询 -- 缓存友好的查询结构 - -### 迁移和种子 - -项目包含完整的数据库迁移和种子文件: -- `20250830014825_create_articles_table.mjs` - 创建articles表 -- `20250830020000_add_article_fields.mjs` - 添加额外字段 -- `20250830020000_articles_seed.mjs` - 示例数据 - -### 运行迁移和种子 - -```bash -# 运行迁移 -npx knex migrate:latest - -# 运行种子 -npx knex seed:run -``` diff --git a/src/db/docs/BookmarkModel.md b/src/db/docs/BookmarkModel.md deleted file mode 100644 index 273129b..0000000 --- a/src/db/docs/BookmarkModel.md +++ /dev/null @@ -1,194 +0,0 @@ -# 数据库模型文档 - -## BookmarkModel - -BookmarkModel 是一个书签管理模型,提供了用户书签的CRUD操作和查询方法,支持URL去重和用户隔离。 - -### 主要特性 - -- ✅ 完整的CRUD操作 -- ✅ 用户隔离的书签管理 -- ✅ URL去重验证 -- ✅ 自动时间戳管理 -- ✅ 外键关联用户表 - -### 数据库字段 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | integer | 主键,自增 | -| user_id | integer | 用户ID(外键,关联users表) | -| title | string(200) | 书签标题(必填,最大长度200) | -| url | string(500) | 书签URL | -| description | text | 书签描述 | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | - -### 外键关系 - -- `user_id` 关联 `users.id` -- 删除用户时,相关书签会自动删除(CASCADE) - -### 基本用法 - -```javascript -import { BookmarkModel } from '../models/BookmarkModel.js' - -// 创建书签 -const bookmark = await BookmarkModel.create({ - user_id: 1, - title: "GitHub - 开源代码托管平台", - url: "https://github.com", - description: "全球最大的代码托管平台" -}) - -// 查找用户的所有书签 -const userBookmarks = await BookmarkModel.findAllByUser(1) - -// 根据ID查找书签 -const bookmark = await BookmarkModel.findById(1) - -// 更新书签 -await BookmarkModel.update(1, { - title: "GitHub - 更新后的标题", - description: "更新后的描述" -}) - -// 删除书签 -await BookmarkModel.delete(1) - -// 查找用户特定URL的书签 -const bookmark = await BookmarkModel.findByUserAndUrl(1, "https://github.com") -``` - -### 查询方法 - -#### 基础查询 -- `findAllByUser(userId)` - 查找指定用户的所有书签(按ID降序) -- `findById(id)` - 根据ID查找书签 -- `findByUserAndUrl(userId, url)` - 查找用户特定URL的书签 - -#### 数据操作 -- `create(data)` - 创建新书签 -- `update(id, data)` - 更新书签信息 -- `delete(id)` - 删除书签 - -### 数据验证和约束 - -#### 必填字段 -- `user_id` - 用户ID不能为空 -- `title` - 标题不能为空 - -#### 唯一性约束 -- 同一用户下不能存在相同URL的书签 -- 系统会自动检查并阻止重复URL的创建 - -#### URL处理 -- URL会自动去除首尾空格 -- 支持最大500字符的URL长度 - -### 去重逻辑 - -#### 创建时去重 -```javascript -// 创建书签时会自动检查是否已存在相同URL -const exists = await db("bookmarks").where({ - user_id: userId, - url: url -}).first() - -if (exists) { - throw new Error("该用户下已存在相同 URL 的书签") -} -``` - -#### 更新时去重 -```javascript -// 更新时会检查新URL是否与其他书签冲突(排除自身) -const exists = await db("bookmarks") - .where({ user_id: nextUserId, url: nextUrl }) - .andWhereNot({ id }) - .first() - -if (exists) { - throw new Error("该用户下已存在相同 URL 的书签") -} -``` - -### 时间戳管理 - -系统自动管理以下时间戳: -- `created_at` - 创建时自动设置为当前时间 -- `updated_at` - 每次更新时自动设置为当前时间 - -### 错误处理 - -所有方法都包含适当的错误处理: -```javascript -try { - const bookmark = await BookmarkModel.create({ - user_id: 1, - title: "重复的书签", - url: "https://example.com" // 如果已存在会抛出错误 - }) -} catch (error) { - console.error("创建书签失败:", error.message) -} -``` - -### 性能优化 - -- `user_id` 字段已添加索引,提高查询性能 -- 支持按用户ID快速查询书签列表 - -### 迁移和种子 - -项目包含完整的数据库迁移文件: -- `20250830015422_create_bookmarks_table.mjs` - 创建bookmarks表 - -### 运行迁移 - -```bash -# 运行迁移 -npx knex migrate:latest -``` - -### 使用场景 - -#### 个人书签管理 -```javascript -// 用户登录后查看自己的书签 -const myBookmarks = await BookmarkModel.findAllByUser(currentUserId) -``` - -#### 书签同步 -```javascript -// 支持多设备书签同步 -const bookmarks = await BookmarkModel.findAllByUser(userId) -// 可以导出为JSON或其他格式 -``` - -#### 书签分享 -```javascript -// 可以扩展实现书签分享功能 -// 通过添加 share_status 字段实现 -``` - -### 扩展建议 - -可以考虑添加以下功能: -- 书签分类和标签 -- 书签收藏夹 -- 书签导入/导出 -- 书签搜索功能 -- 书签访问统计 -- 书签分享功能 -- 书签同步功能 -- 书签备份和恢复 - -### 安全注意事项 - -1. **用户隔离**: 确保用户只能访问自己的书签 -2. **URL验证**: 在应用层验证URL的有效性 -3. **输入清理**: 对用户输入进行适当的清理和验证 -4. **权限控制**: 实现适当的访问控制机制 diff --git a/src/db/docs/README.md b/src/db/docs/README.md deleted file mode 100644 index 16a5aec..0000000 --- a/src/db/docs/README.md +++ /dev/null @@ -1,252 +0,0 @@ -# 数据库文档总览 - -本文档提供了整个数据库系统的概览,包括所有模型、表结构和关系。 - -## 数据库概览 - -这是一个基于 Koa3 和 Knex.js 构建的现代化 Web 应用数据库系统,使用 SQLite 作为数据库引擎。 - -### 技术栈 - -- **数据库**: SQLite3 -- **ORM**: Knex.js -- **迁移工具**: Knex Migrations -- **种子数据**: Knex Seeds -- **数据库驱动**: sqlite3 - -## 数据模型总览 - -### 1. UserModel - 用户管理 -- **表名**: `users` -- **功能**: 用户账户管理、身份验证、角色控制 -- **主要字段**: id, username, email, password, role, phone, age -- **文档**: [UserModel.md](./UserModel.md) - -### 2. ArticleModel - 文章管理 -- **表名**: `articles` -- **功能**: 文章CRUD、状态管理、SEO优化、标签分类 -- **主要字段**: id, title, content, author, category, tags, status, slug -- **文档**: [ArticleModel.md](./ArticleModel.md) - -### 3. BookmarkModel - 书签管理 -- **表名**: `bookmarks` -- **功能**: 用户书签管理、URL去重、用户隔离 -- **主要字段**: id, user_id, title, url, description -- **文档**: [BookmarkModel.md](./BookmarkModel.md) - -### 4. SiteConfigModel - 网站配置 -- **表名**: `site_config` -- **功能**: 键值对配置存储、系统设置管理 -- **主要字段**: id, key, value -- **文档**: [SiteConfigModel.md](./SiteConfigModel.md) - -## 数据库表结构 - -### 表关系图 - -``` -users (用户表) -├── id (主键) -├── username -├── email -├── password -├── role -├── phone -├── age -├── created_at -└── updated_at - -articles (文章表) -├── id (主键) -├── title -├── content -├── author -├── category -├── tags -├── status -├── slug -├── published_at -├── view_count -├── featured_image -├── excerpt -├── reading_time -├── meta_title -├── meta_description -├── keywords -├── description -├── created_at -└── updated_at - -bookmarks (书签表) -├── id (主键) -├── user_id (外键 -> users.id) -├── title -├── url -├── description -├── created_at -└── updated_at - -site_config (网站配置表) -├── id (主键) -├── key (唯一) -├── value -├── created_at -└── updated_at -``` - -### 外键关系 - -- `bookmarks.user_id` → `users.id` (CASCADE 删除) -- 其他表之间暂无直接外键关系 - -## 数据库迁移文件 - -| 迁移文件 | 描述 | 创建时间 | -|----------|------|----------| -| `20250616065041_create_users_table.mjs` | 创建用户表 | 2025-06-16 | -| `20250621013128_site_config.mjs` | 创建网站配置表 | 2025-06-21 | -| `20250830014825_create_articles_table.mjs` | 创建文章表 | 2025-08-30 | -| `20250830015422_create_bookmarks_table.mjs` | 创建书签表 | 2025-08-30 | -| `20250830020000_add_article_fields.mjs` | 添加文章额外字段 | 2025-08-30 | - -## 种子数据文件 - -| 种子文件 | 描述 | 创建时间 | -|----------|------|----------| -| `20250616071157_users_seed.mjs` | 用户示例数据 | 2025-06-16 | -| `20250621013324_site_config_seed.mjs` | 网站配置示例数据 | 2025-06-21 | -| `20250830020000_articles_seed.mjs` | 文章示例数据 | 2025-08-30 | - -## 快速开始 - -### 1. 安装依赖 - -```bash -npm install -# 或 -bun install -``` - -### 2. 运行数据库迁移 - -```bash -# 运行所有迁移 -npx knex migrate:latest - -# 回滚迁移 -npx knex migrate:rollback - -# 查看迁移状态 -npx knex migrate:status -``` - -### 3. 运行种子数据 - -```bash -# 运行所有种子 -npx knex seed:run - -# 运行特定种子 -npx knex seed:run --specific=20250616071157_users_seed.mjs -``` - -### 4. 数据库连接 - -```bash -# 查看数据库配置 -cat knexfile.mjs - -# 连接数据库 -npx knex --knexfile knexfile.mjs -``` - -## 开发指南 - -### 创建新的迁移文件 - -```bash -npx knex migrate:make create_new_table -``` - -### 创建新的种子文件 - -```bash -npx knex seed:make new_seed_data -``` - -### 创建新的模型 - -1. 在 `src/db/models/` 目录下创建新的模型文件 -2. 在 `src/db/docs/` 目录下创建对应的文档 -3. 更新本文档的模型总览部分 - -## 最佳实践 - -### 1. 模型设计原则 - -- 每个模型对应一个数据库表 -- 使用静态方法提供数据操作接口 -- 实现适当的错误处理和验证 -- 支持软删除和审计字段 - -### 2. 迁移管理 - -- 迁移文件一旦提交到版本控制,不要修改 -- 使用描述性的迁移文件名 -- 在迁移文件中添加适当的注释 -- 测试迁移的回滚功能 - -### 3. 种子数据 - -- 种子数据应该包含测试和开发所需的最小数据集 -- 避免在生产环境中运行种子 -- 种子数据应该是幂等的(可重复运行) - -### 4. 性能优化 - -- 为常用查询字段添加索引 -- 使用批量操作减少数据库查询 -- 实现适当的缓存机制 -- 监控查询性能 - -## 故障排除 - -### 常见问题 - -1. **迁移失败** - - 检查数据库连接配置 - - 确保数据库文件存在且有写入权限 - - 查看迁移文件语法是否正确 - -2. **种子数据失败** - - 检查表结构是否与种子数据匹配 - - 确保外键关系正确 - - 查看是否有唯一性约束冲突 - -3. **模型查询错误** - - 检查表名和字段名是否正确 - - 确保数据库连接正常 - - 查看SQL查询日志 - -### 调试技巧 - -```bash -# 启用SQL查询日志 -DEBUG=knex:query node your-app.js - -# 查看数据库结构 -npx knex --knexfile knexfile.mjs -.tables -.schema users -``` - -## 贡献指南 - -1. 遵循现有的代码风格和命名规范 -2. 为新功能添加适当的测试 -3. 更新相关文档 -4. 提交前运行迁移和种子测试 - -## 许可证 - -本项目采用 MIT 许可证。 diff --git a/src/db/docs/SiteConfigModel.md b/src/db/docs/SiteConfigModel.md deleted file mode 100644 index 64b03d5..0000000 --- a/src/db/docs/SiteConfigModel.md +++ /dev/null @@ -1,246 +0,0 @@ -# 数据库模型文档 - -## SiteConfigModel - -SiteConfigModel 是一个网站配置管理模型,提供了灵活的键值对配置存储和管理功能,支持单个配置项和批量配置操作。 - -### 主要特性 - -- ✅ 键值对配置存储 -- ✅ 单个和批量配置操作 -- ✅ 自动时间戳管理 -- ✅ 配置项唯一性保证 -- ✅ 灵活的配置值类型支持 - -### 数据库字段 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | integer | 主键,自增 | -| key | string(100) | 配置项键名(必填,唯一,最大长度100) | -| value | text | 配置项值(必填) | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | - -### 基本用法 - -```javascript -import { SiteConfigModel } from '../models/SiteConfigModel.js' - -// 设置单个配置项 -await SiteConfigModel.set("site_name", "我的网站") -await SiteConfigModel.set("site_description", "一个优秀的网站") -await SiteConfigModel.set("maintenance_mode", "false") - -// 获取单个配置项 -const siteName = await SiteConfigModel.get("site_name") -// 返回: "我的网站" - -// 批量获取配置项 -const configs = await SiteConfigModel.getMany([ - "site_name", - "site_description", - "maintenance_mode" -]) -// 返回: { site_name: "我的网站", site_description: "一个优秀的网站", maintenance_mode: "false" } - -// 获取所有配置 -const allConfigs = await SiteConfigModel.getAll() -// 返回所有配置项的键值对对象 -``` - -### 核心方法 - -#### 单个配置操作 -- `get(key)` - 获取指定key的配置值 -- `set(key, value)` - 设置配置项(有则更新,无则插入) - -#### 批量配置操作 -- `getMany(keys)` - 批量获取多个key的配置值 -- `getAll()` - 获取所有配置项 - -### 配置管理策略 - -#### 自动更新机制 -```javascript -// set方法会自动处理配置项的创建和更新 -static async set(key, value) { - const exists = await db("site_config").where({ key }).first() - if (exists) { - // 如果配置项存在,则更新 - await db("site_config").where({ key }).update({ - value, - updated_at: db.fn.now() - }) - } else { - // 如果配置项不存在,则创建 - await db("site_config").insert({ key, value }) - } -} -``` - -#### 批量获取优化 -```javascript -// 批量获取时使用 whereIn 优化查询性能 -static async getMany(keys) { - const rows = await db("site_config").whereIn("key", keys) - const result = {} - rows.forEach(row => { - result[row.key] = row.value - }) - return result -} -``` - -### 配置值类型支持 - -支持多种配置值类型: - -#### 字符串配置 -```javascript -await SiteConfigModel.set("site_name", "我的网站") -await SiteConfigModel.set("contact_email", "admin@example.com") -``` - -#### 布尔值配置 -```javascript -await SiteConfigModel.set("maintenance_mode", "false") -await SiteConfigModel.set("debug_mode", "true") -``` - -#### 数字配置 -```javascript -await SiteConfigModel.set("max_upload_size", "10485760") // 10MB -await SiteConfigModel.set("session_timeout", "3600") // 1小时 -``` - -#### JSON配置 -```javascript -await SiteConfigModel.set("social_links", JSON.stringify({ - twitter: "https://twitter.com/example", - facebook: "https://facebook.com/example" -})) -``` - -### 使用场景 - -#### 网站基本信息配置 -```javascript -// 设置网站基本信息 -await SiteConfigModel.set("site_name", "我的博客") -await SiteConfigModel.set("site_description", "分享技术和生活") -await SiteConfigModel.set("site_keywords", "技术,博客,编程") -await SiteConfigModel.set("site_author", "张三") -``` - -#### 功能开关配置 -```javascript -// 功能开关 -await SiteConfigModel.set("enable_comments", "true") -await SiteConfigModel.set("enable_registration", "false") -await SiteConfigModel.set("enable_analytics", "true") -``` - -#### 系统配置 -```javascript -// 系统配置 -await SiteConfigModel.set("max_login_attempts", "5") -await SiteConfigModel.set("password_min_length", "8") -await SiteConfigModel.set("session_timeout", "3600") -``` - -#### 第三方服务配置 -```javascript -// 第三方服务配置 -await SiteConfigModel.set("google_analytics_id", "GA-XXXXXXXXX") -await SiteConfigModel.set("recaptcha_site_key", "6LcXXXXXXXX") -await SiteConfigModel.set("smtp_host", "smtp.gmail.com") -``` - -### 配置获取和缓存 - -#### 基础获取 -```javascript -// 获取网站名称 -const siteName = await SiteConfigModel.get("site_name") || "默认网站名称" - -// 获取维护模式状态 -const isMaintenance = await SiteConfigModel.get("maintenance_mode") === "true" -``` - -#### 批量获取优化 -```javascript -// 一次性获取多个配置项,减少数据库查询 -const configs = await SiteConfigModel.getMany([ - "site_name", - "site_description", - "maintenance_mode" -]) - -// 使用配置 -if (configs.maintenance_mode === "true") { - console.log("网站维护中") -} else { - console.log(`欢迎访问 ${configs.site_name}`) -} -``` - -### 错误处理 - -所有方法都包含适当的错误处理: -```javascript -try { - const siteName = await SiteConfigModel.get("site_name") - if (!siteName) { - console.log("网站名称未配置,使用默认值") - return "默认网站名称" - } - return siteName -} catch (error) { - console.error("获取配置失败:", error.message) - return "默认网站名称" -} -``` - -### 性能优化 - -- `key` 字段已添加唯一索引,提高查询性能 -- 支持批量操作,减少数据库查询次数 -- 建议在应用层实现配置缓存机制 - -### 迁移和种子 - -项目包含完整的数据库迁移和种子文件: -- `20250621013128_site_config.mjs` - 创建site_config表 -- `20250621013324_site_config_seed.mjs` - 示例配置数据 - -### 运行迁移和种子 - -```bash -# 运行迁移 -npx knex migrate:latest - -# 运行种子 -npx knex seed:run -``` - -### 扩展建议 - -可以考虑添加以下功能: -- 配置项分类管理 -- 配置项验证规则 -- 配置变更历史记录 -- 配置导入/导出功能 -- 配置项权限控制 -- 配置项版本管理 -- 配置项依赖关系 -- 配置项加密存储 - -### 最佳实践 - -1. **配置项命名**: 使用清晰的命名规范,如 `feature_name` 或 `service_config` -2. **配置值类型**: 统一配置值的类型,如布尔值统一使用字符串 "true"/"false" -3. **配置分组**: 使用前缀对配置项进行分组,如 `email_`, `social_`, `system_` -4. **默认值处理**: 在应用层为配置项提供合理的默认值 -5. **配置验证**: 在设置配置项时验证值的有效性 -6. **配置缓存**: 实现配置缓存机制,减少数据库查询 diff --git a/src/db/docs/UserModel.md b/src/db/docs/UserModel.md deleted file mode 100644 index c8bb373..0000000 --- a/src/db/docs/UserModel.md +++ /dev/null @@ -1,158 +0,0 @@ -# 数据库模型文档 - -## UserModel - -UserModel 是一个用户管理模型,提供了基本的用户CRUD操作和查询方法。 - -### 主要特性 - -- ✅ 完整的CRUD操作 -- ✅ 用户身份验证支持 -- ✅ 用户名和邮箱唯一性验证 -- ✅ 角色管理 -- ✅ 时间戳自动管理 - -### 数据库字段 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | integer | 主键,自增 | -| username | string(100) | 用户名(必填,最大长度100) | -| email | string(100) | 邮箱(唯一) | -| password | string(100) | 密码(必填) | -| role | string(100) | 用户角色(必填) | -| phone | string(100) | 电话号码 | -| age | integer | 年龄(无符号整数) | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | - -### 基本用法 - -```javascript -import { UserModel } from '../models/UserModel.js' - -// 创建用户 -const user = await UserModel.create({ - username: "zhangsan", - email: "zhangsan@example.com", - password: "hashedPassword", - role: "user", - phone: "13800138000", - age: 25 -}) - -// 查找所有用户 -const allUsers = await UserModel.findAll() - -// 根据ID查找用户 -const user = await UserModel.findById(1) - -// 根据用户名查找用户 -const user = await UserModel.findByUsername("zhangsan") - -// 根据邮箱查找用户 -const user = await UserModel.findByEmail("zhangsan@example.com") - -// 更新用户信息 -await UserModel.update(1, { - phone: "13900139000", - age: 26 -}) - -// 删除用户 -await UserModel.delete(1) -``` - -### 查询方法 - -#### 基础查询 -- `findAll()` - 查找所有用户 -- `findById(id)` - 根据ID查找用户 -- `findByUsername(username)` - 根据用户名查找用户 -- `findByEmail(email)` - 根据邮箱查找用户 - -#### 数据操作 -- `create(data)` - 创建新用户 -- `update(id, data)` - 更新用户信息 -- `delete(id)` - 删除用户 - -### 数据验证 - -#### 必填字段 -- `username` - 用户名不能为空 -- `password` - 密码不能为空 -- `role` - 角色不能为空 - -#### 唯一性约束 -- `email` - 邮箱必须唯一 -- `username` - 建议在应用层实现唯一性验证 - -### 时间戳管理 - -系统自动管理以下时间戳: -- `created_at` - 创建时自动设置为当前时间 -- `updated_at` - 每次更新时自动设置为当前时间 - -### 角色管理 - -支持用户角色字段,可用于权限控制: -```javascript -// 常见角色示例 -const roles = { - admin: "管理员", - user: "普通用户", - moderator: "版主" -} -``` - -### 错误处理 - -所有方法都包含适当的错误处理: -```javascript -try { - const user = await UserModel.create({ - username: "", // 空用户名会抛出错误 - password: "password" - }) -} catch (error) { - console.error("创建用户失败:", error.message) -} -``` - -### 性能优化 - -- 建议为 `username` 和 `email` 字段添加索引 -- 支持分页查询(需要扩展实现) - -### 迁移和种子 - -项目包含完整的数据库迁移和种子文件: -- `20250616065041_create_users_table.mjs` - 创建users表 -- `20250616071157_users_seed.mjs` - 示例用户数据 - -### 运行迁移和种子 - -```bash -# 运行迁移 -npx knex migrate:latest - -# 运行种子 -npx knex seed:run -``` - -### 安全注意事项 - -1. **密码安全**: 在创建用户前,确保密码已经过哈希处理 -2. **输入验证**: 在应用层验证用户输入数据的有效性 -3. **权限控制**: 根据用户角色实现适当的访问控制 -4. **SQL注入防护**: 使用Knex.js的参数化查询防止SQL注入 - -### 扩展建议 - -可以考虑添加以下功能: -- 用户状态管理(激活/禁用) -- 密码重置功能 -- 用户头像管理 -- 用户偏好设置 -- 登录历史记录 -- 用户组管理 diff --git a/src/db/index.js b/src/db/index.js deleted file mode 100644 index fcab69a..0000000 --- a/src/db/index.js +++ /dev/null @@ -1,149 +0,0 @@ -import buildKnex from "knex" -import knexConfig from "../../knexfile.mjs" - -// 简单内存缓存(支持 TTL 与按前缀清理) -const queryCache = new Map() - -const getNow = () => Date.now() - -const computeExpiresAt = (ttlMs) => { - if (!ttlMs || ttlMs <= 0) return null - return getNow() + ttlMs -} - -const isExpired = (entry) => { - if (!entry) return true - if (entry.expiresAt == null) return false - return entry.expiresAt <= getNow() -} - -const getCacheKeyForBuilder = (builder) => { - if (builder._customCacheKey) return String(builder._customCacheKey) - return builder.toString() -} - -// 全局工具,便于在 QL 外部操作缓存 -export const DbQueryCache = { - get(key) { - const entry = queryCache.get(String(key)) - if (!entry) return undefined - if (isExpired(entry)) { - queryCache.delete(String(key)) - return undefined - } - return entry.value - }, - set(key, value, ttlMs) { - const expiresAt = computeExpiresAt(ttlMs) - queryCache.set(String(key), { value, expiresAt }) - return value - }, - has(key) { - const entry = queryCache.get(String(key)) - return !!entry && !isExpired(entry) - }, - delete(key) { - return queryCache.delete(String(key)) - }, - clear() { - queryCache.clear() - }, - clearByPrefix(prefix) { - const p = String(prefix) - for (const k of queryCache.keys()) { - if (k.startsWith(p)) queryCache.delete(k) - } - }, - stats() { - let valid = 0 - let expired = 0 - for (const [k, entry] of queryCache.entries()) { - if (isExpired(entry)) expired++ - else valid++ - } - return { size: queryCache.size, valid, expired } - } -} - -// QueryBuilder 扩展 -// 1) cache(ttlMs?): 读取缓存,不存在则执行并写入 -buildKnex.QueryBuilder.extend("cache", async function (ttlMs) { - const key = getCacheKeyForBuilder(this) - const entry = queryCache.get(key) - if (entry && !isExpired(entry)) { - return entry.value - } - const data = await this - queryCache.set(key, { value: data, expiresAt: computeExpiresAt(ttlMs) }) - return data -}) - -// 2) cacheAs(customKey): 设置自定义 key -buildKnex.QueryBuilder.extend("cacheAs", function (customKey) { - this._customCacheKey = String(customKey) - return this -}) - -// 3) cacheSet(value, ttlMs?): 手动设置当前查询 key 的缓存 -buildKnex.QueryBuilder.extend("cacheSet", function (value, ttlMs) { - const key = getCacheKeyForBuilder(this) - queryCache.set(key, { value, expiresAt: computeExpiresAt(ttlMs) }) - return value -}) - -// 4) cacheGet(): 仅从缓存读取当前查询 key 的值 -buildKnex.QueryBuilder.extend("cacheGet", function () { - const key = getCacheKeyForBuilder(this) - const entry = queryCache.get(key) - if (!entry || isExpired(entry)) return undefined - return entry.value -}) - -// 5) cacheInvalidate(): 使当前查询 key 的缓存失效 -buildKnex.QueryBuilder.extend("cacheInvalidate", function () { - const key = getCacheKeyForBuilder(this) - queryCache.delete(key) - return this -}) - -// 6) cacheInvalidateByPrefix(prefix): 按前缀清理 -buildKnex.QueryBuilder.extend("cacheInvalidateByPrefix", function (prefix) { - const p = String(prefix) - for (const k of queryCache.keys()) { - if (k.startsWith(p)) queryCache.delete(k) - } - return this -}) - -const environment = process.env.NODE_ENV || "development" -const db = buildKnex(knexConfig[environment]) - -export default db - -// async function createDatabase() { -// try { -// // SQLite会自动创建数据库文件,只需验证连接 -// await db.raw("SELECT 1") -// console.log("SQLite数据库连接成功") - -// // 检查users表是否存在(示例) -// const [tableExists] = await db.raw(` -// SELECT name -// FROM sqlite_master -// WHERE type='table' AND name='users' -// `) - -// if (tableExists) { -// console.log("表 users 已存在") -// } else { -// console.log("表 users 不存在,需要创建(通过迁移)") -// } - -// await db.destroy() -// } catch (error) { -// console.error("数据库操作失败:", error) -// process.exit(1) -// } -// } - -// createDatabase() diff --git a/src/db/migrations/20250616065041_create_users_table.mjs b/src/db/migrations/20250616065041_create_users_table.mjs deleted file mode 100644 index a431899..0000000 --- a/src/db/migrations/20250616065041_create_users_table.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.createTable("users", function (table) { - table.increments("id").primary() // 自增主键 - table.string("username", 100).notNullable() // 字符串字段(最大长度100) - table.string("email", 100).unique() // 唯一邮箱 - table.string("password", 100).notNullable() // 密码 - table.string("role", 100).notNullable() - table.string("phone", 100) - table.integer("age").unsigned() // 无符号整数 - table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 - table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.dropTable("users") // 回滚时删除表 -} diff --git a/src/db/migrations/20250621013128_site_config.mjs b/src/db/migrations/20250621013128_site_config.mjs deleted file mode 100644 index 87e998b..0000000 --- a/src/db/migrations/20250621013128_site_config.mjs +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.createTable("site_config", function (table) { - table.increments("id").primary() // 自增主键 - table.string("key", 100).notNullable().unique() // 配置项key,唯一 - table.text("value").notNullable() // 配置项value - table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 - table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.dropTable("site_config") // 回滚时删除表 -} diff --git a/src/db/migrations/20250830014825_create_articles_table.mjs b/src/db/migrations/20250830014825_create_articles_table.mjs deleted file mode 100644 index 7dcf1b9..0000000 --- a/src/db/migrations/20250830014825_create_articles_table.mjs +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.createTable("articles", table => { - table.increments("id").primary() - table.string("title").notNullable() - table.string("content").notNullable() - table.string("author") - table.string("category") - table.string("tags") - table.string("keywords") - table.string("description") - table.timestamp("created_at").defaultTo(knex.fn.now()) - table.timestamp("updated_at").defaultTo(knex.fn.now()) - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.dropTable("articles") -} diff --git a/src/db/migrations/20250830015422_create_bookmarks_table.mjs b/src/db/migrations/20250830015422_create_bookmarks_table.mjs deleted file mode 100644 index 52ff3cc..0000000 --- a/src/db/migrations/20250830015422_create_bookmarks_table.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.createTable("bookmarks", function (table) { - table.increments("id").primary() - table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") - table.string("title", 200).notNullable() - table.string("url", 500) - table.text("description") - table.timestamp("created_at").defaultTo(knex.fn.now()) - table.timestamp("updated_at").defaultTo(knex.fn.now()) - - table.index(["user_id"]) // 常用查询索引 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.dropTable("bookmarks") -} diff --git a/src/db/migrations/20250830020000_add_article_fields.mjs b/src/db/migrations/20250830020000_add_article_fields.mjs deleted file mode 100644 index 2775c57..0000000 --- a/src/db/migrations/20250830020000_add_article_fields.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.alterTable("articles", table => { - // 添加浏览量字段 - table.integer("view_count").defaultTo(0) - - // 添加发布时间字段 - table.timestamp("published_at") - - // 添加状态字段 (draft, published, archived) - table.string("status").defaultTo("draft") - - // 添加特色图片字段 - table.string("featured_image") - - // 添加摘要字段 - table.text("excerpt") - - // 添加阅读时间估算字段(分钟) - table.integer("reading_time") - - // 添加SEO相关字段 - table.string("meta_title") - table.text("meta_description") - table.string("slug").unique() - - // 添加索引以提高查询性能 - table.index(["status", "published_at"]) - table.index(["category"]) - table.index(["author"]) - table.index(["created_at"]) - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.alterTable("articles", table => { - table.dropColumn("view_count") - table.dropColumn("published_at") - table.dropColumn("status") - table.dropColumn("featured_image") - table.dropColumn("excerpt") - table.dropColumn("reading_time") - table.dropColumn("meta_title") - table.dropColumn("meta_description") - table.dropColumn("slug") - - // 删除索引 - table.dropIndex(["status", "published_at"]) - table.dropIndex(["category"]) - table.dropIndex(["author"]) - table.dropIndex(["created_at"]) - }) -} diff --git a/src/db/migrations/20250901000000_add_profile_fields.mjs b/src/db/migrations/20250901000000_add_profile_fields.mjs deleted file mode 100644 index 3f27c22..0000000 --- a/src/db/migrations/20250901000000_add_profile_fields.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.alterTable("users", function (table) { - table.string("name", 100) // 昵称 - table.text("bio") // 个人简介 - table.string("avatar", 500) // 头像URL - table.string("status", 20).defaultTo("active") // 用户状态 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.alterTable("users", function (table) { - table.dropColumn("name") - table.dropColumn("bio") - table.dropColumn("avatar") - table.dropColumn("status") - }) -} diff --git a/src/db/models/ArticleModel.js b/src/db/models/ArticleModel.js deleted file mode 100644 index 4bf5fa9..0000000 --- a/src/db/models/ArticleModel.js +++ /dev/null @@ -1,290 +0,0 @@ -import db from "../index.js" - -class ArticleModel { - static async findAll() { - return db("articles").orderBy("created_at", "desc") - } - - static async findPublished(offset, limit) { - let query = db("articles") - .where("status", "published") - .whereNotNull("published_at") - .orderBy("published_at", "desc") - if (typeof offset === "number") { - query = query.offset(offset) - } - if (typeof limit === "number") { - query = query.limit(limit) - } - return query - } - - static async findDrafts() { - return db("articles").where("status", "draft").orderBy("updated_at", "desc") - } - - static async findById(id) { - return db("articles").where("id", id).first() - } - - static async findBySlug(slug) { - return db("articles").where("slug", slug).first() - } - - static async findByAuthor(author) { - return db("articles").where("author", author).where("status", "published").orderBy("published_at", "desc") - } - - static async findByCategory(category) { - return db("articles").where("category", category).where("status", "published").orderBy("published_at", "desc") - } - - static async findByTags(tags) { - // 支持多个标签搜索,标签以逗号分隔 - const tagArray = tags.split(",").map(tag => tag.trim()) - return db("articles") - .where("status", "published") - .whereRaw("tags LIKE ?", [`%${tagArray[0]}%`]) - .orderBy("published_at", "desc") - } - - static async searchByKeyword(keyword) { - return db("articles") - .where("status", "published") - .where(function () { - this.where("title", "like", `%${keyword}%`) - .orWhere("content", "like", `%${keyword}%`) - .orWhere("keywords", "like", `%${keyword}%`) - .orWhere("description", "like", `%${keyword}%`) - .orWhere("excerpt", "like", `%${keyword}%`) - }) - .orderBy("published_at", "desc") - } - - static async create(data) { - // 验证必填字段 - if (!data.title || !data.content) { - throw new Error("标题和内容为必填字段") - } - - // 处理标签,确保格式一致 - let tags = data.tags - if (tags && typeof tags === "string") { - tags = tags - .split(",") - .map(tag => tag.trim()) - .filter(tag => tag) - .join(", ") - } - - // 生成slug(如果未提供) - let slug = data.slug - if (!slug) { - slug = this.generateSlug(data.title) - } - - // 计算阅读时间(如果未提供) - let readingTime = data.reading_time - if (!readingTime) { - readingTime = this.calculateReadingTime(data.content) - } - - // 生成摘要(如果未提供) - let excerpt = data.excerpt - if (!excerpt && data.content) { - excerpt = this.generateExcerpt(data.content) - } - - return db("articles") - .insert({ - ...data, - tags, - slug, - reading_time: readingTime, - excerpt, - status: data.status || "draft", - view_count: 0, - created_at: db.fn.now(), - updated_at: db.fn.now(), - }) - .returning("*") - } - - static async update(id, data) { - const current = await db("articles").where("id", id).first() - if (!current) { - throw new Error("文章不存在") - } - - // 处理标签,确保格式一致 - let tags = data.tags - if (tags && typeof tags === "string") { - tags = tags - .split(",") - .map(tag => tag.trim()) - .filter(tag => tag) - .join(", ") - } - - // 生成slug(如果标题改变且未提供slug) - let slug = data.slug - if (data.title && data.title !== current.title && !slug) { - slug = this.generateSlug(data.title) - } - - // 计算阅读时间(如果内容改变且未提供) - let readingTime = data.reading_time - if (data.content && data.content !== current.content && !readingTime) { - readingTime = this.calculateReadingTime(data.content) - } - - // 生成摘要(如果内容改变且未提供) - let excerpt = data.excerpt - if (data.content && data.content !== current.content && !excerpt) { - excerpt = this.generateExcerpt(data.content) - } - - // 如果状态改为published,设置发布时间 - let publishedAt = data.published_at - if (data.status === "published" && current.status !== "published" && !publishedAt) { - publishedAt = db.fn.now() - } - - return db("articles") - .where("id", id) - .update({ - ...data, - tags: tags || current.tags, - slug: slug || current.slug, - reading_time: readingTime || current.reading_time, - excerpt: excerpt || current.excerpt, - published_at: publishedAt || current.published_at, - updated_at: db.fn.now(), - }) - .returning("*") - } - - static async delete(id) { - const article = await db("articles").where("id", id).first() - if (!article) { - throw new Error("文章不存在") - } - return db("articles").where("id", id).del() - } - - static async publish(id) { - return db("articles") - .where("id", id) - .update({ - status: "published", - published_at: db.fn.now(), - updated_at: db.fn.now(), - }) - .returning("*") - } - - static async unpublish(id) { - return db("articles") - .where("id", id) - .update({ - status: "draft", - published_at: null, - updated_at: db.fn.now(), - }) - .returning("*") - } - - static async incrementViewCount(id) { - return db("articles").where("id", id).increment("view_count", 1).returning("*") - } - - static async findByDateRange(startDate, endDate) { - return db("articles") - .where("status", "published") - .whereBetween("published_at", [startDate, endDate]) - .orderBy("published_at", "desc") - } - - static async getArticleCount() { - const result = await db("articles").count("id as count").first() - return result ? result.count : 0 - } - - static async getPublishedArticleCount() { - const result = await db("articles").where("status", "published").count("id as count").first() - return result ? result.count : 0 - } - - static async getArticleCountByCategory() { - return db("articles") - .select("category") - .count("id as count") - .where("status", "published") - .groupBy("category") - .orderBy("count", "desc") - } - - static async getArticleCountByStatus() { - return db("articles").select("status").count("id as count").groupBy("status").orderBy("count", "desc") - } - - static async getRecentArticles(limit = 10) { - return db("articles").where("status", "published").orderBy("published_at", "desc").limit(limit) - } - - static async getPopularArticles(limit = 10) { - return db("articles").where("status", "published").orderBy("view_count", "desc").limit(limit) - } - - static async getFeaturedArticles(limit = 5) { - return db("articles").where("status", "published").whereNotNull("featured_image").orderBy("published_at", "desc").limit(limit) - } - - static async getRelatedArticles(articleId, limit = 5) { - const current = await this.findById(articleId) - if (!current) return [] - - return db("articles") - .where("status", "published") - .where("id", "!=", articleId) - .where(function () { - if (current.category) { - this.orWhere("category", current.category) - } - if (current.tags) { - const tags = current.tags.split(",").map(tag => tag.trim()) - tags.forEach(tag => { - this.orWhereRaw("tags LIKE ?", [`%${tag}%`]) - }) - } - }) - .orderBy("published_at", "desc") - .limit(limit) - } - - // 工具方法 - static generateSlug(title) { - return title - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .trim() - } - - static calculateReadingTime(content) { - // 假设平均阅读速度为每分钟200个单词 - const wordCount = content.split(/\s+/).length - return Math.ceil(wordCount / 200) - } - - static generateExcerpt(content, maxLength = 150) { - if (content.length <= maxLength) { - return content - } - return content.substring(0, maxLength).trim() + "..." - } -} - -export default ArticleModel -export { ArticleModel } diff --git a/src/db/models/BookmarkModel.js b/src/db/models/BookmarkModel.js deleted file mode 100644 index 3fb6968..0000000 --- a/src/db/models/BookmarkModel.js +++ /dev/null @@ -1,68 +0,0 @@ -import db from "../index.js" - -class BookmarkModel { - static async findAllByUser(userId) { - return db("bookmarks").where("user_id", userId).orderBy("id", "desc") - } - - static async findById(id) { - return db("bookmarks").where("id", id).first() - } - - static async create(data) { - const userId = data.user_id - const url = typeof data.url === "string" ? data.url.trim() : data.url - - if (userId != null && url) { - const exists = await db("bookmarks").where({ user_id: userId, url }).first() - if (exists) { - throw new Error("该用户下已存在相同 URL 的书签") - } - } - - return db("bookmarks").insert({ - ...data, - url, - updated_at: db.fn.now(), - }).returning("*") - } - - static async update(id, data) { - // 若更新后 user_id 与 url 同时存在,则做排他性查重(排除自身) - const current = await db("bookmarks").where("id", id).first() - if (!current) return [] - - const nextUserId = data.user_id != null ? data.user_id : current.user_id - const nextUrlRaw = data.url != null ? data.url : current.url - const nextUrl = typeof nextUrlRaw === "string" ? nextUrlRaw.trim() : nextUrlRaw - - if (nextUserId != null && nextUrl) { - const exists = await db("bookmarks") - .where({ user_id: nextUserId, url: nextUrl }) - .andWhereNot({ id }) - .first() - if (exists) { - throw new Error("该用户下已存在相同 URL 的书签") - } - } - - return db("bookmarks").where("id", id).update({ - ...data, - url: data.url != null ? nextUrl : data.url, - updated_at: db.fn.now(), - }).returning("*") - } - - static async delete(id) { - return db("bookmarks").where("id", id).del() - } - - static async findByUserAndUrl(userId, url) { - return db("bookmarks").where({ user_id: userId, url }).first() - } -} - -export default BookmarkModel -export { BookmarkModel } - - diff --git a/src/db/models/SiteConfigModel.js b/src/db/models/SiteConfigModel.js deleted file mode 100644 index 7e69fe0..0000000 --- a/src/db/models/SiteConfigModel.js +++ /dev/null @@ -1,42 +0,0 @@ -import db from "../index.js" - -class SiteConfigModel { - // 获取指定key的配置 - static async get(key) { - const row = await db("site_config").where({ key }).first() - return row ? row.value : null - } - - // 设置指定key的配置(有则更新,无则插入) - static async set(key, value) { - const exists = await db("site_config").where({ key }).first() - if (exists) { - await db("site_config").where({ key }).update({ value, updated_at: db.fn.now() }) - } else { - await db("site_config").insert({ key, value }) - } - } - - // 批量获取多个key的配置 - static async getMany(keys) { - const rows = await db("site_config").whereIn("key", keys) - const result = {} - rows.forEach(row => { - result[row.key] = row.value - }) - return result - } - - // 获取所有配置 - static async getAll() { - const rows = await db("site_config").select("key", "value") - const result = {} - rows.forEach(row => { - result[row.key] = row.value - }) - return result - } -} - -export default SiteConfigModel -export { SiteConfigModel } \ No newline at end of file diff --git a/src/db/models/UserModel.js b/src/db/models/UserModel.js deleted file mode 100644 index bf9fc03..0000000 --- a/src/db/models/UserModel.js +++ /dev/null @@ -1,36 +0,0 @@ -import db from "../index.js" - -class UserModel { - static async findAll() { - return db("users").select("*") - } - - static async findById(id) { - return db("users").where("id", id).first() - } - - static async create(data) { - return db("users").insert({ - ...data, - updated_at: db.fn.now(), - }).returning("*") - } - - static async update(id, data) { - return db("users").where("id", id).update(data).returning("*") - } - - static async delete(id) { - return db("users").where("id", id).del() - } - - static async findByUsername(username) { - return db("users").where("username", username).first() - } - static async findByEmail(email) { - return db("users").where("email", email).first() - } -} - -export default UserModel -export { UserModel } diff --git a/src/db/seeds/20250616071157_users_seed.mjs b/src/db/seeds/20250616071157_users_seed.mjs deleted file mode 100644 index 6093d2b..0000000 --- a/src/db/seeds/20250616071157_users_seed.mjs +++ /dev/null @@ -1,17 +0,0 @@ -export const seed = async knex => { -// 检查表是否存在 -const hasUsersTable = await knex.schema.hasTable('users'); - -if (!hasUsersTable) { - console.error("表 users 不存在,请先执行迁移") - return -} - // Deletes ALL existing entries - await knex("users").del() - - // Inserts seed entries - // await knex("users").insert([ - // { username: "Alice", email: "alice@example.com" }, - // { username: "Bob", email: "bob@example.com" }, - // ]) -} diff --git a/src/db/seeds/20250621013324_site_config_seed.mjs b/src/db/seeds/20250621013324_site_config_seed.mjs deleted file mode 100644 index ec3c7c5..0000000 --- a/src/db/seeds/20250621013324_site_config_seed.mjs +++ /dev/null @@ -1,15 +0,0 @@ -export const seed = async (knex) => { - // 删除所有已有配置 - await knex('site_config').del(); - - // 插入常用站点配置项 - await knex('site_config').insert([ - { key: 'site_title', value: '罗非鱼的秘密' }, - { key: 'site_author', value: '罗非鱼' }, - { key: 'site_author_avatar', value: 'https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg' }, - { key: 'site_description', value: '一屋很小,却也很大' }, - { key: 'site_logo', value: '/static/logo.png' }, - { key: 'site_bg', value: '/static/bg.jpg' }, - { key: 'keywords', value: 'blog' } - ]); -}; diff --git a/src/db/seeds/20250830020000_articles_seed.mjs b/src/db/seeds/20250830020000_articles_seed.mjs deleted file mode 100644 index 0dea864..0000000 --- a/src/db/seeds/20250830020000_articles_seed.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const seed = async knex => { - // 清空表 - await knex("articles").del() - - // 插入示例数据 - await knex("articles").insert([ - { - title: "欢迎使用文章管理系统", - content: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理。系统提供了丰富的功能,包括标签管理、分类管理、SEO优化等。\n\n## 主要特性\n\n- 支持Markdown格式\n- 标签和分类管理\n- SEO优化\n- 阅读时间计算\n- 浏览量统计\n- 草稿和发布状态管理", - author: "系统管理员", - category: "系统介绍", - tags: "系统, 介绍, 功能", - keywords: "文章管理, 系统介绍, 功能特性", - description: "介绍文章管理系统的主要功能和特性", - status: "published", - published_at: knex.fn.now(), - excerpt: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理...", - reading_time: 3, - slug: "welcome-to-article-management-system", - meta_title: "欢迎使用文章管理系统 - 功能特性介绍", - meta_description: "了解文章管理系统的主要功能,包括Markdown支持、标签管理、SEO优化等特性" - }, - { - title: "Markdown 写作指南", - content: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档。\n\n## 基本语法\n\n### 标题\n使用 `#` 符号创建标题:\n\n```markdown\n# 一级标题\n## 二级标题\n### 三级标题\n```\n\n### 列表\n- 无序列表使用 `-` 或 `*`\n- 有序列表使用数字\n\n### 链接和图片\n[链接文本](URL)\n![图片描述](图片URL)\n\n### 代码\n使用反引号标记行内代码:`code`\n\n使用代码块:\n```javascript\nfunction hello() {\n console.log('Hello World!');\n}\n```", - author: "技术编辑", - category: "写作指南", - tags: "Markdown, 写作, 指南", - keywords: "Markdown, 写作指南, 语法, 教程", - description: "详细介绍Markdown的基本语法和用法,帮助用户快速掌握Markdown写作", - status: "published", - published_at: knex.fn.now(), - excerpt: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档...", - reading_time: 8, - slug: "markdown-writing-guide", - meta_title: "Markdown 写作指南 - 从入门到精通", - meta_description: "学习Markdown的基本语法,包括标题、列表、链接、图片、代码等常用元素的写法" - }, - { - title: "SEO 优化最佳实践", - content: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。\n\n## 关键词研究\n\n关键词研究是SEO的基础,需要:\n- 了解目标受众的搜索习惯\n- 分析竞争对手的关键词\n- 选择合适的关键词密度\n\n## 内容优化\n\n### 标题优化\n- 标题应包含主要关键词\n- 标题长度控制在50-60字符\n- 使用吸引人的标题\n\n### 内容结构\n- 使用H1-H6标签组织内容\n- 段落要简洁明了\n- 添加相关图片和视频\n\n## 技术SEO\n\n- 确保网站加载速度快\n- 优化移动端体验\n- 使用结构化数据\n- 建立内部链接结构", - author: "SEO专家", - category: "数字营销", - tags: "SEO, 优化, 搜索引擎, 营销", - keywords: "SEO优化, 搜索引擎优化, 关键词研究, 内容优化", - description: "介绍SEO优化的最佳实践,包括关键词研究、内容优化和技术SEO等方面", - status: "published", - published_at: knex.fn.now(), - excerpt: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。本文介绍SEO优化的最佳实践...", - reading_time: 12, - slug: "seo-optimization-best-practices", - meta_title: "SEO 优化最佳实践 - 提升网站排名", - meta_description: "学习SEO优化的关键技巧,包括关键词研究、内容优化和技术SEO,帮助提升网站在搜索引擎中的排名" - }, - { - title: "前端开发趋势 2024", - content: "2024年前端开发领域出现了许多新的趋势和技术。\n\n## 主要趋势\n\n### 1. 框架发展\n- React 18的新特性\n- Vue 3的Composition API\n- Svelte的崛起\n\n### 2. 构建工具\n- Vite的快速构建\n- Webpack 5的模块联邦\n- Turbopack的性能提升\n\n### 3. 性能优化\n- 核心Web指标\n- 图片优化\n- 代码分割\n\n### 4. 新特性\n- CSS容器查询\n- CSS Grid布局\n- Web Components\n\n## 学习建议\n\n建议开发者关注这些趋势,但不要盲目追新,要根据项目需求选择合适的技术栈。", - author: "前端开发者", - category: "技术趋势", - tags: "前端, 开发, 趋势, 2024", - keywords: "前端开发, 技术趋势, React, Vue, 性能优化", - description: "分析2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等方面", - status: "draft", - excerpt: "2024年前端开发领域出现了许多新的趋势和技术。本文分析主要趋势并提供学习建议...", - reading_time: 10, - slug: "frontend-development-trends-2024", - meta_title: "前端开发趋势 2024 - 技术发展分析", - meta_description: "了解2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等,为技术选型提供参考" - } - ]) - - console.log("✅ Articles seeded successfully!") -} diff --git a/src/global.js b/src/global.js deleted file mode 100644 index c5274e9..0000000 --- a/src/global.js +++ /dev/null @@ -1,21 +0,0 @@ -import Koa from "koa" -import { logger } from "./logger.js" -import { validateEnvironment } from "./utils/envValidator.js" - -// 启动前验证环境变量 -if (!validateEnvironment()) { - logger.error("环境变量验证失败,应用退出") - process.exit(1) -} - -const app = new Koa({ asyncLocalStorage: true }) - -app.keys = [] - -// SESSION_SECRET 已通过环境变量验证确保存在 -process.env.SESSION_SECRET.split(",").forEach(secret => { - app.keys.push(secret.trim()) -}) - -export { app } -export default app \ No newline at end of file diff --git a/src/infrastructure/database/docs/ArticleModel.md b/src/infrastructure/database/docs/ArticleModel.md new file mode 100644 index 0000000..c7e3d93 --- /dev/null +++ b/src/infrastructure/database/docs/ArticleModel.md @@ -0,0 +1,190 @@ +# 数据库模型文档 + +## ArticleModel + +ArticleModel 是一个功能完整的文章管理模型,提供了丰富的CRUD操作和查询方法。 + +### 主要特性 + +- ✅ 完整的CRUD操作 +- ✅ 文章状态管理(草稿、已发布、已归档) +- ✅ 自动生成slug、摘要和阅读时间 +- ✅ 标签和分类管理 +- ✅ SEO优化支持 +- ✅ 浏览量统计 +- ✅ 相关文章推荐 +- ✅ 全文搜索功能 + +### 数据库字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | integer | 主键,自增 | +| title | string | 文章标题(必填) | +| content | text | 文章内容(必填) | +| author | string | 作者 | +| category | string | 分类 | +| tags | string | 标签(逗号分隔) | +| keywords | string | SEO关键词 | +| description | string | 文章描述 | +| status | string | 状态:draft/published/archived | +| published_at | timestamp | 发布时间 | +| view_count | integer | 浏览量 | +| featured_image | string | 特色图片 | +| excerpt | text | 文章摘要 | +| reading_time | integer | 阅读时间(分钟) | +| meta_title | string | SEO标题 | +| meta_description | text | SEO描述 | +| slug | string | URL友好的标识符 | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +### 基本用法 + +```javascript +import { ArticleModel } from '../models/ArticleModel.js' + +// 创建文章 +const article = await ArticleModel.create({ + title: "我的第一篇文章", + content: "这是文章内容...", + author: "张三", + category: "技术", + tags: "JavaScript, Node.js, 教程" +}) + +// 查找所有已发布的文章 +const publishedArticles = await ArticleModel.findPublished() + +// 根据ID查找文章 +const article = await ArticleModel.findById(1) + +// 更新文章 +await ArticleModel.update(1, { + title: "更新后的标题", + content: "更新后的内容" +}) + +// 发布文章 +await ArticleModel.publish(1) + +// 删除文章 +await ArticleModel.delete(1) +``` + +### 查询方法 + +#### 基础查询 +- `findAll()` - 查找所有文章 +- `findById(id)` - 根据ID查找文章 +- `findBySlug(slug)` - 根据slug查找文章 +- `findPublished()` - 查找所有已发布的文章 +- `findDrafts()` - 查找所有草稿文章 + +#### 分类查询 +- `findByAuthor(author)` - 根据作者查找文章 +- `findByCategory(category)` - 根据分类查找文章 +- `findByTags(tags)` - 根据标签查找文章 + +#### 搜索功能 +- `searchByKeyword(keyword)` - 关键词搜索(标题、内容、关键词、描述、摘要) + +#### 统计功能 +- `getArticleCount()` - 获取文章总数 +- `getPublishedArticleCount()` - 获取已发布文章数量 +- `getArticleCountByCategory()` - 按分类统计文章数量 +- `getArticleCountByStatus()` - 按状态统计文章数量 + +#### 推荐功能 +- `getRecentArticles(limit)` - 获取最新文章 +- `getPopularArticles(limit)` - 获取热门文章 +- `getFeaturedArticles(limit)` - 获取特色文章 +- `getRelatedArticles(articleId, limit)` - 获取相关文章 + +#### 高级查询 +- `findByDateRange(startDate, endDate)` - 按日期范围查找文章 +- `incrementViewCount(id)` - 增加浏览量 + +### 状态管理 + +文章支持三种状态: +- `draft` - 草稿状态 +- `published` - 已发布状态 +- `archived` - 已归档状态 + +```javascript +// 发布文章 +await ArticleModel.publish(articleId) + +// 取消发布 +await ArticleModel.unpublish(articleId) +``` + +### 自动功能 + +#### 自动生成slug +如果未提供slug,系统会自动根据标题生成: +```javascript +// 标题: "我的第一篇文章" +// 自动生成slug: "我的第一篇文章" +``` + +#### 自动计算阅读时间 +基于内容长度自动计算阅读时间(假设每分钟200个单词) + +#### 自动生成摘要 +如果未提供摘要,系统会自动从内容中提取前150个字符 + +### 标签管理 + +标签支持逗号分隔的格式,系统会自动处理: +```javascript +// 输入: "JavaScript, Node.js, 教程" +// 存储: "JavaScript, Node.js, 教程" +// 查询: 支持模糊匹配 +``` + +### SEO优化 + +支持完整的SEO字段: +- `meta_title` - 页面标题 +- `meta_description` - 页面描述 +- `keywords` - 关键词 +- `slug` - URL友好的标识符 + +### 错误处理 + +所有方法都包含适当的错误处理: +```javascript +try { + const article = await ArticleModel.create({ + title: "", // 空标题会抛出错误 + content: "内容" + }) +} catch (error) { + console.error("创建文章失败:", error.message) +} +``` + +### 性能优化 + +- 所有查询都包含适当的索引 +- 支持分页查询 +- 缓存友好的查询结构 + +### 迁移和种子 + +项目包含完整的数据库迁移和种子文件: +- `20250830014825_create_articles_table.mjs` - 创建articles表 +- `20250830020000_add_article_fields.mjs` - 添加额外字段 +- `20250830020000_articles_seed.mjs` - 示例数据 + +### 运行迁移和种子 + +```bash +# 运行迁移 +npx knex migrate:latest + +# 运行种子 +npx knex seed:run +``` diff --git a/src/infrastructure/database/docs/BookmarkModel.md b/src/infrastructure/database/docs/BookmarkModel.md new file mode 100644 index 0000000..273129b --- /dev/null +++ b/src/infrastructure/database/docs/BookmarkModel.md @@ -0,0 +1,194 @@ +# 数据库模型文档 + +## BookmarkModel + +BookmarkModel 是一个书签管理模型,提供了用户书签的CRUD操作和查询方法,支持URL去重和用户隔离。 + +### 主要特性 + +- ✅ 完整的CRUD操作 +- ✅ 用户隔离的书签管理 +- ✅ URL去重验证 +- ✅ 自动时间戳管理 +- ✅ 外键关联用户表 + +### 数据库字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | integer | 主键,自增 | +| user_id | integer | 用户ID(外键,关联users表) | +| title | string(200) | 书签标题(必填,最大长度200) | +| url | string(500) | 书签URL | +| description | text | 书签描述 | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +### 外键关系 + +- `user_id` 关联 `users.id` +- 删除用户时,相关书签会自动删除(CASCADE) + +### 基本用法 + +```javascript +import { BookmarkModel } from '../models/BookmarkModel.js' + +// 创建书签 +const bookmark = await BookmarkModel.create({ + user_id: 1, + title: "GitHub - 开源代码托管平台", + url: "https://github.com", + description: "全球最大的代码托管平台" +}) + +// 查找用户的所有书签 +const userBookmarks = await BookmarkModel.findAllByUser(1) + +// 根据ID查找书签 +const bookmark = await BookmarkModel.findById(1) + +// 更新书签 +await BookmarkModel.update(1, { + title: "GitHub - 更新后的标题", + description: "更新后的描述" +}) + +// 删除书签 +await BookmarkModel.delete(1) + +// 查找用户特定URL的书签 +const bookmark = await BookmarkModel.findByUserAndUrl(1, "https://github.com") +``` + +### 查询方法 + +#### 基础查询 +- `findAllByUser(userId)` - 查找指定用户的所有书签(按ID降序) +- `findById(id)` - 根据ID查找书签 +- `findByUserAndUrl(userId, url)` - 查找用户特定URL的书签 + +#### 数据操作 +- `create(data)` - 创建新书签 +- `update(id, data)` - 更新书签信息 +- `delete(id)` - 删除书签 + +### 数据验证和约束 + +#### 必填字段 +- `user_id` - 用户ID不能为空 +- `title` - 标题不能为空 + +#### 唯一性约束 +- 同一用户下不能存在相同URL的书签 +- 系统会自动检查并阻止重复URL的创建 + +#### URL处理 +- URL会自动去除首尾空格 +- 支持最大500字符的URL长度 + +### 去重逻辑 + +#### 创建时去重 +```javascript +// 创建书签时会自动检查是否已存在相同URL +const exists = await db("bookmarks").where({ + user_id: userId, + url: url +}).first() + +if (exists) { + throw new Error("该用户下已存在相同 URL 的书签") +} +``` + +#### 更新时去重 +```javascript +// 更新时会检查新URL是否与其他书签冲突(排除自身) +const exists = await db("bookmarks") + .where({ user_id: nextUserId, url: nextUrl }) + .andWhereNot({ id }) + .first() + +if (exists) { + throw new Error("该用户下已存在相同 URL 的书签") +} +``` + +### 时间戳管理 + +系统自动管理以下时间戳: +- `created_at` - 创建时自动设置为当前时间 +- `updated_at` - 每次更新时自动设置为当前时间 + +### 错误处理 + +所有方法都包含适当的错误处理: +```javascript +try { + const bookmark = await BookmarkModel.create({ + user_id: 1, + title: "重复的书签", + url: "https://example.com" // 如果已存在会抛出错误 + }) +} catch (error) { + console.error("创建书签失败:", error.message) +} +``` + +### 性能优化 + +- `user_id` 字段已添加索引,提高查询性能 +- 支持按用户ID快速查询书签列表 + +### 迁移和种子 + +项目包含完整的数据库迁移文件: +- `20250830015422_create_bookmarks_table.mjs` - 创建bookmarks表 + +### 运行迁移 + +```bash +# 运行迁移 +npx knex migrate:latest +``` + +### 使用场景 + +#### 个人书签管理 +```javascript +// 用户登录后查看自己的书签 +const myBookmarks = await BookmarkModel.findAllByUser(currentUserId) +``` + +#### 书签同步 +```javascript +// 支持多设备书签同步 +const bookmarks = await BookmarkModel.findAllByUser(userId) +// 可以导出为JSON或其他格式 +``` + +#### 书签分享 +```javascript +// 可以扩展实现书签分享功能 +// 通过添加 share_status 字段实现 +``` + +### 扩展建议 + +可以考虑添加以下功能: +- 书签分类和标签 +- 书签收藏夹 +- 书签导入/导出 +- 书签搜索功能 +- 书签访问统计 +- 书签分享功能 +- 书签同步功能 +- 书签备份和恢复 + +### 安全注意事项 + +1. **用户隔离**: 确保用户只能访问自己的书签 +2. **URL验证**: 在应用层验证URL的有效性 +3. **输入清理**: 对用户输入进行适当的清理和验证 +4. **权限控制**: 实现适当的访问控制机制 diff --git a/src/infrastructure/database/docs/README.md b/src/infrastructure/database/docs/README.md new file mode 100644 index 0000000..16a5aec --- /dev/null +++ b/src/infrastructure/database/docs/README.md @@ -0,0 +1,252 @@ +# 数据库文档总览 + +本文档提供了整个数据库系统的概览,包括所有模型、表结构和关系。 + +## 数据库概览 + +这是一个基于 Koa3 和 Knex.js 构建的现代化 Web 应用数据库系统,使用 SQLite 作为数据库引擎。 + +### 技术栈 + +- **数据库**: SQLite3 +- **ORM**: Knex.js +- **迁移工具**: Knex Migrations +- **种子数据**: Knex Seeds +- **数据库驱动**: sqlite3 + +## 数据模型总览 + +### 1. UserModel - 用户管理 +- **表名**: `users` +- **功能**: 用户账户管理、身份验证、角色控制 +- **主要字段**: id, username, email, password, role, phone, age +- **文档**: [UserModel.md](./UserModel.md) + +### 2. ArticleModel - 文章管理 +- **表名**: `articles` +- **功能**: 文章CRUD、状态管理、SEO优化、标签分类 +- **主要字段**: id, title, content, author, category, tags, status, slug +- **文档**: [ArticleModel.md](./ArticleModel.md) + +### 3. BookmarkModel - 书签管理 +- **表名**: `bookmarks` +- **功能**: 用户书签管理、URL去重、用户隔离 +- **主要字段**: id, user_id, title, url, description +- **文档**: [BookmarkModel.md](./BookmarkModel.md) + +### 4. SiteConfigModel - 网站配置 +- **表名**: `site_config` +- **功能**: 键值对配置存储、系统设置管理 +- **主要字段**: id, key, value +- **文档**: [SiteConfigModel.md](./SiteConfigModel.md) + +## 数据库表结构 + +### 表关系图 + +``` +users (用户表) +├── id (主键) +├── username +├── email +├── password +├── role +├── phone +├── age +├── created_at +└── updated_at + +articles (文章表) +├── id (主键) +├── title +├── content +├── author +├── category +├── tags +├── status +├── slug +├── published_at +├── view_count +├── featured_image +├── excerpt +├── reading_time +├── meta_title +├── meta_description +├── keywords +├── description +├── created_at +└── updated_at + +bookmarks (书签表) +├── id (主键) +├── user_id (外键 -> users.id) +├── title +├── url +├── description +├── created_at +└── updated_at + +site_config (网站配置表) +├── id (主键) +├── key (唯一) +├── value +├── created_at +└── updated_at +``` + +### 外键关系 + +- `bookmarks.user_id` → `users.id` (CASCADE 删除) +- 其他表之间暂无直接外键关系 + +## 数据库迁移文件 + +| 迁移文件 | 描述 | 创建时间 | +|----------|------|----------| +| `20250616065041_create_users_table.mjs` | 创建用户表 | 2025-06-16 | +| `20250621013128_site_config.mjs` | 创建网站配置表 | 2025-06-21 | +| `20250830014825_create_articles_table.mjs` | 创建文章表 | 2025-08-30 | +| `20250830015422_create_bookmarks_table.mjs` | 创建书签表 | 2025-08-30 | +| `20250830020000_add_article_fields.mjs` | 添加文章额外字段 | 2025-08-30 | + +## 种子数据文件 + +| 种子文件 | 描述 | 创建时间 | +|----------|------|----------| +| `20250616071157_users_seed.mjs` | 用户示例数据 | 2025-06-16 | +| `20250621013324_site_config_seed.mjs` | 网站配置示例数据 | 2025-06-21 | +| `20250830020000_articles_seed.mjs` | 文章示例数据 | 2025-08-30 | + +## 快速开始 + +### 1. 安装依赖 + +```bash +npm install +# 或 +bun install +``` + +### 2. 运行数据库迁移 + +```bash +# 运行所有迁移 +npx knex migrate:latest + +# 回滚迁移 +npx knex migrate:rollback + +# 查看迁移状态 +npx knex migrate:status +``` + +### 3. 运行种子数据 + +```bash +# 运行所有种子 +npx knex seed:run + +# 运行特定种子 +npx knex seed:run --specific=20250616071157_users_seed.mjs +``` + +### 4. 数据库连接 + +```bash +# 查看数据库配置 +cat knexfile.mjs + +# 连接数据库 +npx knex --knexfile knexfile.mjs +``` + +## 开发指南 + +### 创建新的迁移文件 + +```bash +npx knex migrate:make create_new_table +``` + +### 创建新的种子文件 + +```bash +npx knex seed:make new_seed_data +``` + +### 创建新的模型 + +1. 在 `src/db/models/` 目录下创建新的模型文件 +2. 在 `src/db/docs/` 目录下创建对应的文档 +3. 更新本文档的模型总览部分 + +## 最佳实践 + +### 1. 模型设计原则 + +- 每个模型对应一个数据库表 +- 使用静态方法提供数据操作接口 +- 实现适当的错误处理和验证 +- 支持软删除和审计字段 + +### 2. 迁移管理 + +- 迁移文件一旦提交到版本控制,不要修改 +- 使用描述性的迁移文件名 +- 在迁移文件中添加适当的注释 +- 测试迁移的回滚功能 + +### 3. 种子数据 + +- 种子数据应该包含测试和开发所需的最小数据集 +- 避免在生产环境中运行种子 +- 种子数据应该是幂等的(可重复运行) + +### 4. 性能优化 + +- 为常用查询字段添加索引 +- 使用批量操作减少数据库查询 +- 实现适当的缓存机制 +- 监控查询性能 + +## 故障排除 + +### 常见问题 + +1. **迁移失败** + - 检查数据库连接配置 + - 确保数据库文件存在且有写入权限 + - 查看迁移文件语法是否正确 + +2. **种子数据失败** + - 检查表结构是否与种子数据匹配 + - 确保外键关系正确 + - 查看是否有唯一性约束冲突 + +3. **模型查询错误** + - 检查表名和字段名是否正确 + - 确保数据库连接正常 + - 查看SQL查询日志 + +### 调试技巧 + +```bash +# 启用SQL查询日志 +DEBUG=knex:query node your-app.js + +# 查看数据库结构 +npx knex --knexfile knexfile.mjs +.tables +.schema users +``` + +## 贡献指南 + +1. 遵循现有的代码风格和命名规范 +2. 为新功能添加适当的测试 +3. 更新相关文档 +4. 提交前运行迁移和种子测试 + +## 许可证 + +本项目采用 MIT 许可证。 diff --git a/src/infrastructure/database/docs/SiteConfigModel.md b/src/infrastructure/database/docs/SiteConfigModel.md new file mode 100644 index 0000000..64b03d5 --- /dev/null +++ b/src/infrastructure/database/docs/SiteConfigModel.md @@ -0,0 +1,246 @@ +# 数据库模型文档 + +## SiteConfigModel + +SiteConfigModel 是一个网站配置管理模型,提供了灵活的键值对配置存储和管理功能,支持单个配置项和批量配置操作。 + +### 主要特性 + +- ✅ 键值对配置存储 +- ✅ 单个和批量配置操作 +- ✅ 自动时间戳管理 +- ✅ 配置项唯一性保证 +- ✅ 灵活的配置值类型支持 + +### 数据库字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | integer | 主键,自增 | +| key | string(100) | 配置项键名(必填,唯一,最大长度100) | +| value | text | 配置项值(必填) | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +### 基本用法 + +```javascript +import { SiteConfigModel } from '../models/SiteConfigModel.js' + +// 设置单个配置项 +await SiteConfigModel.set("site_name", "我的网站") +await SiteConfigModel.set("site_description", "一个优秀的网站") +await SiteConfigModel.set("maintenance_mode", "false") + +// 获取单个配置项 +const siteName = await SiteConfigModel.get("site_name") +// 返回: "我的网站" + +// 批量获取配置项 +const configs = await SiteConfigModel.getMany([ + "site_name", + "site_description", + "maintenance_mode" +]) +// 返回: { site_name: "我的网站", site_description: "一个优秀的网站", maintenance_mode: "false" } + +// 获取所有配置 +const allConfigs = await SiteConfigModel.getAll() +// 返回所有配置项的键值对对象 +``` + +### 核心方法 + +#### 单个配置操作 +- `get(key)` - 获取指定key的配置值 +- `set(key, value)` - 设置配置项(有则更新,无则插入) + +#### 批量配置操作 +- `getMany(keys)` - 批量获取多个key的配置值 +- `getAll()` - 获取所有配置项 + +### 配置管理策略 + +#### 自动更新机制 +```javascript +// set方法会自动处理配置项的创建和更新 +static async set(key, value) { + const exists = await db("site_config").where({ key }).first() + if (exists) { + // 如果配置项存在,则更新 + await db("site_config").where({ key }).update({ + value, + updated_at: db.fn.now() + }) + } else { + // 如果配置项不存在,则创建 + await db("site_config").insert({ key, value }) + } +} +``` + +#### 批量获取优化 +```javascript +// 批量获取时使用 whereIn 优化查询性能 +static async getMany(keys) { + const rows = await db("site_config").whereIn("key", keys) + const result = {} + rows.forEach(row => { + result[row.key] = row.value + }) + return result +} +``` + +### 配置值类型支持 + +支持多种配置值类型: + +#### 字符串配置 +```javascript +await SiteConfigModel.set("site_name", "我的网站") +await SiteConfigModel.set("contact_email", "admin@example.com") +``` + +#### 布尔值配置 +```javascript +await SiteConfigModel.set("maintenance_mode", "false") +await SiteConfigModel.set("debug_mode", "true") +``` + +#### 数字配置 +```javascript +await SiteConfigModel.set("max_upload_size", "10485760") // 10MB +await SiteConfigModel.set("session_timeout", "3600") // 1小时 +``` + +#### JSON配置 +```javascript +await SiteConfigModel.set("social_links", JSON.stringify({ + twitter: "https://twitter.com/example", + facebook: "https://facebook.com/example" +})) +``` + +### 使用场景 + +#### 网站基本信息配置 +```javascript +// 设置网站基本信息 +await SiteConfigModel.set("site_name", "我的博客") +await SiteConfigModel.set("site_description", "分享技术和生活") +await SiteConfigModel.set("site_keywords", "技术,博客,编程") +await SiteConfigModel.set("site_author", "张三") +``` + +#### 功能开关配置 +```javascript +// 功能开关 +await SiteConfigModel.set("enable_comments", "true") +await SiteConfigModel.set("enable_registration", "false") +await SiteConfigModel.set("enable_analytics", "true") +``` + +#### 系统配置 +```javascript +// 系统配置 +await SiteConfigModel.set("max_login_attempts", "5") +await SiteConfigModel.set("password_min_length", "8") +await SiteConfigModel.set("session_timeout", "3600") +``` + +#### 第三方服务配置 +```javascript +// 第三方服务配置 +await SiteConfigModel.set("google_analytics_id", "GA-XXXXXXXXX") +await SiteConfigModel.set("recaptcha_site_key", "6LcXXXXXXXX") +await SiteConfigModel.set("smtp_host", "smtp.gmail.com") +``` + +### 配置获取和缓存 + +#### 基础获取 +```javascript +// 获取网站名称 +const siteName = await SiteConfigModel.get("site_name") || "默认网站名称" + +// 获取维护模式状态 +const isMaintenance = await SiteConfigModel.get("maintenance_mode") === "true" +``` + +#### 批量获取优化 +```javascript +// 一次性获取多个配置项,减少数据库查询 +const configs = await SiteConfigModel.getMany([ + "site_name", + "site_description", + "maintenance_mode" +]) + +// 使用配置 +if (configs.maintenance_mode === "true") { + console.log("网站维护中") +} else { + console.log(`欢迎访问 ${configs.site_name}`) +} +``` + +### 错误处理 + +所有方法都包含适当的错误处理: +```javascript +try { + const siteName = await SiteConfigModel.get("site_name") + if (!siteName) { + console.log("网站名称未配置,使用默认值") + return "默认网站名称" + } + return siteName +} catch (error) { + console.error("获取配置失败:", error.message) + return "默认网站名称" +} +``` + +### 性能优化 + +- `key` 字段已添加唯一索引,提高查询性能 +- 支持批量操作,减少数据库查询次数 +- 建议在应用层实现配置缓存机制 + +### 迁移和种子 + +项目包含完整的数据库迁移和种子文件: +- `20250621013128_site_config.mjs` - 创建site_config表 +- `20250621013324_site_config_seed.mjs` - 示例配置数据 + +### 运行迁移和种子 + +```bash +# 运行迁移 +npx knex migrate:latest + +# 运行种子 +npx knex seed:run +``` + +### 扩展建议 + +可以考虑添加以下功能: +- 配置项分类管理 +- 配置项验证规则 +- 配置变更历史记录 +- 配置导入/导出功能 +- 配置项权限控制 +- 配置项版本管理 +- 配置项依赖关系 +- 配置项加密存储 + +### 最佳实践 + +1. **配置项命名**: 使用清晰的命名规范,如 `feature_name` 或 `service_config` +2. **配置值类型**: 统一配置值的类型,如布尔值统一使用字符串 "true"/"false" +3. **配置分组**: 使用前缀对配置项进行分组,如 `email_`, `social_`, `system_` +4. **默认值处理**: 在应用层为配置项提供合理的默认值 +5. **配置验证**: 在设置配置项时验证值的有效性 +6. **配置缓存**: 实现配置缓存机制,减少数据库查询 diff --git a/src/infrastructure/database/docs/UserModel.md b/src/infrastructure/database/docs/UserModel.md new file mode 100644 index 0000000..c8bb373 --- /dev/null +++ b/src/infrastructure/database/docs/UserModel.md @@ -0,0 +1,158 @@ +# 数据库模型文档 + +## UserModel + +UserModel 是一个用户管理模型,提供了基本的用户CRUD操作和查询方法。 + +### 主要特性 + +- ✅ 完整的CRUD操作 +- ✅ 用户身份验证支持 +- ✅ 用户名和邮箱唯一性验证 +- ✅ 角色管理 +- ✅ 时间戳自动管理 + +### 数据库字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | integer | 主键,自增 | +| username | string(100) | 用户名(必填,最大长度100) | +| email | string(100) | 邮箱(唯一) | +| password | string(100) | 密码(必填) | +| role | string(100) | 用户角色(必填) | +| phone | string(100) | 电话号码 | +| age | integer | 年龄(无符号整数) | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | + +### 基本用法 + +```javascript +import { UserModel } from '../models/UserModel.js' + +// 创建用户 +const user = await UserModel.create({ + username: "zhangsan", + email: "zhangsan@example.com", + password: "hashedPassword", + role: "user", + phone: "13800138000", + age: 25 +}) + +// 查找所有用户 +const allUsers = await UserModel.findAll() + +// 根据ID查找用户 +const user = await UserModel.findById(1) + +// 根据用户名查找用户 +const user = await UserModel.findByUsername("zhangsan") + +// 根据邮箱查找用户 +const user = await UserModel.findByEmail("zhangsan@example.com") + +// 更新用户信息 +await UserModel.update(1, { + phone: "13900139000", + age: 26 +}) + +// 删除用户 +await UserModel.delete(1) +``` + +### 查询方法 + +#### 基础查询 +- `findAll()` - 查找所有用户 +- `findById(id)` - 根据ID查找用户 +- `findByUsername(username)` - 根据用户名查找用户 +- `findByEmail(email)` - 根据邮箱查找用户 + +#### 数据操作 +- `create(data)` - 创建新用户 +- `update(id, data)` - 更新用户信息 +- `delete(id)` - 删除用户 + +### 数据验证 + +#### 必填字段 +- `username` - 用户名不能为空 +- `password` - 密码不能为空 +- `role` - 角色不能为空 + +#### 唯一性约束 +- `email` - 邮箱必须唯一 +- `username` - 建议在应用层实现唯一性验证 + +### 时间戳管理 + +系统自动管理以下时间戳: +- `created_at` - 创建时自动设置为当前时间 +- `updated_at` - 每次更新时自动设置为当前时间 + +### 角色管理 + +支持用户角色字段,可用于权限控制: +```javascript +// 常见角色示例 +const roles = { + admin: "管理员", + user: "普通用户", + moderator: "版主" +} +``` + +### 错误处理 + +所有方法都包含适当的错误处理: +```javascript +try { + const user = await UserModel.create({ + username: "", // 空用户名会抛出错误 + password: "password" + }) +} catch (error) { + console.error("创建用户失败:", error.message) +} +``` + +### 性能优化 + +- 建议为 `username` 和 `email` 字段添加索引 +- 支持分页查询(需要扩展实现) + +### 迁移和种子 + +项目包含完整的数据库迁移和种子文件: +- `20250616065041_create_users_table.mjs` - 创建users表 +- `20250616071157_users_seed.mjs` - 示例用户数据 + +### 运行迁移和种子 + +```bash +# 运行迁移 +npx knex migrate:latest + +# 运行种子 +npx knex seed:run +``` + +### 安全注意事项 + +1. **密码安全**: 在创建用户前,确保密码已经过哈希处理 +2. **输入验证**: 在应用层验证用户输入数据的有效性 +3. **权限控制**: 根据用户角色实现适当的访问控制 +4. **SQL注入防护**: 使用Knex.js的参数化查询防止SQL注入 + +### 扩展建议 + +可以考虑添加以下功能: +- 用户状态管理(激活/禁用) +- 密码重置功能 +- 用户头像管理 +- 用户偏好设置 +- 登录历史记录 +- 用户组管理 diff --git a/src/infrastructure/database/index.js b/src/infrastructure/database/index.js new file mode 100644 index 0000000..0d884be --- /dev/null +++ b/src/infrastructure/database/index.js @@ -0,0 +1,13 @@ +/** + * 数据库基础设施 + * + * 统一导出数据库相关功能 + */ + +import DatabaseProvider from "../../app/providers/DatabaseProvider.js"; + +// 初始化数据库连接 +const db = DatabaseProvider.register(); + +export default db; +export { DatabaseProvider }; \ No newline at end of file diff --git a/src/infrastructure/database/migrations/20250616065041_create_users_table.mjs b/src/infrastructure/database/migrations/20250616065041_create_users_table.mjs new file mode 100644 index 0000000..a431899 --- /dev/null +++ b/src/infrastructure/database/migrations/20250616065041_create_users_table.mjs @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.createTable("users", function (table) { + table.increments("id").primary() // 自增主键 + table.string("username", 100).notNullable() // 字符串字段(最大长度100) + table.string("email", 100).unique() // 唯一邮箱 + table.string("password", 100).notNullable() // 密码 + table.string("role", 100).notNullable() + table.string("phone", 100) + table.integer("age").unsigned() // 无符号整数 + table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 + table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.dropTable("users") // 回滚时删除表 +} diff --git a/src/infrastructure/database/migrations/20250621013128_site_config.mjs b/src/infrastructure/database/migrations/20250621013128_site_config.mjs new file mode 100644 index 0000000..87e998b --- /dev/null +++ b/src/infrastructure/database/migrations/20250621013128_site_config.mjs @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.createTable("site_config", function (table) { + table.increments("id").primary() // 自增主键 + table.string("key", 100).notNullable().unique() // 配置项key,唯一 + table.text("value").notNullable() // 配置项value + table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 + table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.dropTable("site_config") // 回滚时删除表 +} diff --git a/src/infrastructure/database/migrations/20250830014825_create_articles_table.mjs b/src/infrastructure/database/migrations/20250830014825_create_articles_table.mjs new file mode 100644 index 0000000..7dcf1b9 --- /dev/null +++ b/src/infrastructure/database/migrations/20250830014825_create_articles_table.mjs @@ -0,0 +1,26 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.createTable("articles", table => { + table.increments("id").primary() + table.string("title").notNullable() + table.string("content").notNullable() + table.string("author") + table.string("category") + table.string("tags") + table.string("keywords") + table.string("description") + table.timestamp("created_at").defaultTo(knex.fn.now()) + table.timestamp("updated_at").defaultTo(knex.fn.now()) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.dropTable("articles") +} diff --git a/src/infrastructure/database/migrations/20250830015422_create_bookmarks_table.mjs b/src/infrastructure/database/migrations/20250830015422_create_bookmarks_table.mjs new file mode 100644 index 0000000..52ff3cc --- /dev/null +++ b/src/infrastructure/database/migrations/20250830015422_create_bookmarks_table.mjs @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.createTable("bookmarks", function (table) { + table.increments("id").primary() + table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") + table.string("title", 200).notNullable() + table.string("url", 500) + table.text("description") + table.timestamp("created_at").defaultTo(knex.fn.now()) + table.timestamp("updated_at").defaultTo(knex.fn.now()) + + table.index(["user_id"]) // 常用查询索引 + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.dropTable("bookmarks") +} diff --git a/src/infrastructure/database/migrations/20250830020000_add_article_fields.mjs b/src/infrastructure/database/migrations/20250830020000_add_article_fields.mjs new file mode 100644 index 0000000..2775c57 --- /dev/null +++ b/src/infrastructure/database/migrations/20250830020000_add_article_fields.mjs @@ -0,0 +1,60 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.alterTable("articles", table => { + // 添加浏览量字段 + table.integer("view_count").defaultTo(0) + + // 添加发布时间字段 + table.timestamp("published_at") + + // 添加状态字段 (draft, published, archived) + table.string("status").defaultTo("draft") + + // 添加特色图片字段 + table.string("featured_image") + + // 添加摘要字段 + table.text("excerpt") + + // 添加阅读时间估算字段(分钟) + table.integer("reading_time") + + // 添加SEO相关字段 + table.string("meta_title") + table.text("meta_description") + table.string("slug").unique() + + // 添加索引以提高查询性能 + table.index(["status", "published_at"]) + table.index(["category"]) + table.index(["author"]) + table.index(["created_at"]) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.alterTable("articles", table => { + table.dropColumn("view_count") + table.dropColumn("published_at") + table.dropColumn("status") + table.dropColumn("featured_image") + table.dropColumn("excerpt") + table.dropColumn("reading_time") + table.dropColumn("meta_title") + table.dropColumn("meta_description") + table.dropColumn("slug") + + // 删除索引 + table.dropIndex(["status", "published_at"]) + table.dropIndex(["category"]) + table.dropIndex(["author"]) + table.dropIndex(["created_at"]) + }) +} diff --git a/src/infrastructure/database/migrations/20250901000000_add_profile_fields.mjs b/src/infrastructure/database/migrations/20250901000000_add_profile_fields.mjs new file mode 100644 index 0000000..3f27c22 --- /dev/null +++ b/src/infrastructure/database/migrations/20250901000000_add_profile_fields.mjs @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + return knex.schema.alterTable("users", function (table) { + table.string("name", 100) // 昵称 + table.text("bio") // 个人简介 + table.string("avatar", 500) // 头像URL + table.string("status", 20).defaultTo("active") // 用户状态 + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + return knex.schema.alterTable("users", function (table) { + table.dropColumn("name") + table.dropColumn("bio") + table.dropColumn("avatar") + table.dropColumn("status") + }) +} diff --git a/src/infrastructure/database/seeds/20250616071157_users_seed.mjs b/src/infrastructure/database/seeds/20250616071157_users_seed.mjs new file mode 100644 index 0000000..6093d2b --- /dev/null +++ b/src/infrastructure/database/seeds/20250616071157_users_seed.mjs @@ -0,0 +1,17 @@ +export const seed = async knex => { +// 检查表是否存在 +const hasUsersTable = await knex.schema.hasTable('users'); + +if (!hasUsersTable) { + console.error("表 users 不存在,请先执行迁移") + return +} + // Deletes ALL existing entries + await knex("users").del() + + // Inserts seed entries + // await knex("users").insert([ + // { username: "Alice", email: "alice@example.com" }, + // { username: "Bob", email: "bob@example.com" }, + // ]) +} diff --git a/src/infrastructure/database/seeds/20250621013324_site_config_seed.mjs b/src/infrastructure/database/seeds/20250621013324_site_config_seed.mjs new file mode 100644 index 0000000..ec3c7c5 --- /dev/null +++ b/src/infrastructure/database/seeds/20250621013324_site_config_seed.mjs @@ -0,0 +1,15 @@ +export const seed = async (knex) => { + // 删除所有已有配置 + await knex('site_config').del(); + + // 插入常用站点配置项 + await knex('site_config').insert([ + { key: 'site_title', value: '罗非鱼的秘密' }, + { key: 'site_author', value: '罗非鱼' }, + { key: 'site_author_avatar', value: 'https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg' }, + { key: 'site_description', value: '一屋很小,却也很大' }, + { key: 'site_logo', value: '/static/logo.png' }, + { key: 'site_bg', value: '/static/bg.jpg' }, + { key: 'keywords', value: 'blog' } + ]); +}; diff --git a/src/infrastructure/database/seeds/20250830020000_articles_seed.mjs b/src/infrastructure/database/seeds/20250830020000_articles_seed.mjs new file mode 100644 index 0000000..0dea864 --- /dev/null +++ b/src/infrastructure/database/seeds/20250830020000_articles_seed.mjs @@ -0,0 +1,77 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const seed = async knex => { + // 清空表 + await knex("articles").del() + + // 插入示例数据 + await knex("articles").insert([ + { + title: "欢迎使用文章管理系统", + content: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理。系统提供了丰富的功能,包括标签管理、分类管理、SEO优化等。\n\n## 主要特性\n\n- 支持Markdown格式\n- 标签和分类管理\n- SEO优化\n- 阅读时间计算\n- 浏览量统计\n- 草稿和发布状态管理", + author: "系统管理员", + category: "系统介绍", + tags: "系统, 介绍, 功能", + keywords: "文章管理, 系统介绍, 功能特性", + description: "介绍文章管理系统的主要功能和特性", + status: "published", + published_at: knex.fn.now(), + excerpt: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理...", + reading_time: 3, + slug: "welcome-to-article-management-system", + meta_title: "欢迎使用文章管理系统 - 功能特性介绍", + meta_description: "了解文章管理系统的主要功能,包括Markdown支持、标签管理、SEO优化等特性" + }, + { + title: "Markdown 写作指南", + content: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档。\n\n## 基本语法\n\n### 标题\n使用 `#` 符号创建标题:\n\n```markdown\n# 一级标题\n## 二级标题\n### 三级标题\n```\n\n### 列表\n- 无序列表使用 `-` 或 `*`\n- 有序列表使用数字\n\n### 链接和图片\n[链接文本](URL)\n![图片描述](图片URL)\n\n### 代码\n使用反引号标记行内代码:`code`\n\n使用代码块:\n```javascript\nfunction hello() {\n console.log('Hello World!');\n}\n```", + author: "技术编辑", + category: "写作指南", + tags: "Markdown, 写作, 指南", + keywords: "Markdown, 写作指南, 语法, 教程", + description: "详细介绍Markdown的基本语法和用法,帮助用户快速掌握Markdown写作", + status: "published", + published_at: knex.fn.now(), + excerpt: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档...", + reading_time: 8, + slug: "markdown-writing-guide", + meta_title: "Markdown 写作指南 - 从入门到精通", + meta_description: "学习Markdown的基本语法,包括标题、列表、链接、图片、代码等常用元素的写法" + }, + { + title: "SEO 优化最佳实践", + content: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。\n\n## 关键词研究\n\n关键词研究是SEO的基础,需要:\n- 了解目标受众的搜索习惯\n- 分析竞争对手的关键词\n- 选择合适的关键词密度\n\n## 内容优化\n\n### 标题优化\n- 标题应包含主要关键词\n- 标题长度控制在50-60字符\n- 使用吸引人的标题\n\n### 内容结构\n- 使用H1-H6标签组织内容\n- 段落要简洁明了\n- 添加相关图片和视频\n\n## 技术SEO\n\n- 确保网站加载速度快\n- 优化移动端体验\n- 使用结构化数据\n- 建立内部链接结构", + author: "SEO专家", + category: "数字营销", + tags: "SEO, 优化, 搜索引擎, 营销", + keywords: "SEO优化, 搜索引擎优化, 关键词研究, 内容优化", + description: "介绍SEO优化的最佳实践,包括关键词研究、内容优化和技术SEO等方面", + status: "published", + published_at: knex.fn.now(), + excerpt: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。本文介绍SEO优化的最佳实践...", + reading_time: 12, + slug: "seo-optimization-best-practices", + meta_title: "SEO 优化最佳实践 - 提升网站排名", + meta_description: "学习SEO优化的关键技巧,包括关键词研究、内容优化和技术SEO,帮助提升网站在搜索引擎中的排名" + }, + { + title: "前端开发趋势 2024", + content: "2024年前端开发领域出现了许多新的趋势和技术。\n\n## 主要趋势\n\n### 1. 框架发展\n- React 18的新特性\n- Vue 3的Composition API\n- Svelte的崛起\n\n### 2. 构建工具\n- Vite的快速构建\n- Webpack 5的模块联邦\n- Turbopack的性能提升\n\n### 3. 性能优化\n- 核心Web指标\n- 图片优化\n- 代码分割\n\n### 4. 新特性\n- CSS容器查询\n- CSS Grid布局\n- Web Components\n\n## 学习建议\n\n建议开发者关注这些趋势,但不要盲目追新,要根据项目需求选择合适的技术栈。", + author: "前端开发者", + category: "技术趋势", + tags: "前端, 开发, 趋势, 2024", + keywords: "前端开发, 技术趋势, React, Vue, 性能优化", + description: "分析2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等方面", + status: "draft", + excerpt: "2024年前端开发领域出现了许多新的趋势和技术。本文分析主要趋势并提供学习建议...", + reading_time: 10, + slug: "frontend-development-trends-2024", + meta_title: "前端开发趋势 2024 - 技术发展分析", + meta_description: "了解2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等,为技术选型提供参考" + } + ]) + + console.log("✅ Articles seeded successfully!") +} diff --git a/src/infrastructure/jobs/exampleJob.js b/src/infrastructure/jobs/exampleJob.js new file mode 100644 index 0000000..ca4976d --- /dev/null +++ b/src/infrastructure/jobs/exampleJob.js @@ -0,0 +1,11 @@ +import { jobLogger } from "../../app/bootstrap/logger.js" + +export default { + id: "example", + cronTime: "*/10 * * * * *", // 每10秒执行一次 + task: () => { + jobLogger.info("Example Job 执行了") + }, + options: {}, + autoStart: false, +} diff --git a/src/infrastructure/jobs/index.js b/src/infrastructure/jobs/index.js new file mode 100644 index 0000000..c288f07 --- /dev/null +++ b/src/infrastructure/jobs/index.js @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import scheduler from '../../shared/utils/scheduler.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const jobsDir = __dirname; +const jobModules = {}; + +// 异步加载所有 job 文件 +async function loadJobs() { + const files = fs.readdirSync(jobsDir); + for (const file of files) { + if (file === 'index.js' || !file.endsWith('Job.js')) continue; + try { + const jobModule = await import(`file://${path.join(jobsDir, file)}`); + const job = jobModule.default || jobModule; + if (job && job.id && job.cronTime && typeof job.task === 'function') { + jobModules[job.id] = job; + scheduler.add(job.id, job.cronTime, job.task, job.options); + if (job.autoStart) scheduler.start(job.id); + } + } catch (error) { + console.error(`[Jobs] 加载任务文件 ${file} 失败:`, error); + } + } +} + +// 立即加载所有任务 +loadJobs().catch(console.error); + +function callHook(id, hookName) { + const job = jobModules[id]; + if (job && typeof job[hookName] === 'function') { + try { + job[hookName](); + } catch (e) { + console.error(`[Job:${id}] ${hookName} 执行异常:`, e); + } + } +} + +export default { + start: id => { + callHook(id, 'beforeStart'); + scheduler.start(id); + }, + stop: id => { + scheduler.stop(id); + callHook(id, 'afterStop'); + }, + updateCronTime: (id, cronTime) => scheduler.updateCronTime(id, cronTime), + list: () => scheduler.list(), + reload: id => { + const job = jobModules[id]; + if (job) { + scheduler.remove(id); + scheduler.add(job.id, job.cronTime, job.task, job.options); + } + } +}; diff --git a/src/jobs/exampleJob.js b/src/jobs/exampleJob.js deleted file mode 100644 index 4e0387c..0000000 --- a/src/jobs/exampleJob.js +++ /dev/null @@ -1,11 +0,0 @@ -import { jobLogger } from "@/logger" - -export default { - id: "example", - cronTime: "*/10 * * * * *", // 每10秒执行一次 - task: () => { - jobLogger.info("Example Job 执行了") - }, - options: {}, - autoStart: false, -} diff --git a/src/jobs/index.js b/src/jobs/index.js deleted file mode 100644 index bf8006c..0000000 --- a/src/jobs/index.js +++ /dev/null @@ -1,48 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import scheduler from 'utils/scheduler.js'; - -const jobsDir = __dirname; -const jobModules = {}; - -fs.readdirSync(jobsDir).forEach(file => { - if (file === 'index.js' || !file.endsWith('Job.js')) return; - const jobModule = require(path.join(jobsDir, file)); - const job = jobModule.default || jobModule; - if (job && job.id && job.cronTime && typeof job.task === 'function') { - jobModules[job.id] = job; - scheduler.add(job.id, job.cronTime, job.task, job.options); - if (job.autoStart) scheduler.start(job.id); - } -}); - -function callHook(id, hookName) { - const job = jobModules[id]; - if (job && typeof job[hookName] === 'function') { - try { - job[hookName](); - } catch (e) { - console.error(`[Job:${id}] ${hookName} 执行异常:`, e); - } - } -} - -export default { - start: id => { - callHook(id, 'beforeStart'); - scheduler.start(id); - }, - stop: id => { - scheduler.stop(id); - callHook(id, 'afterStop'); - }, - updateCronTime: (id, cronTime) => scheduler.updateCronTime(id, cronTime), - list: () => scheduler.list(), - reload: id => { - const job = jobModules[id]; - if (job) { - scheduler.remove(id); - scheduler.add(job.id, job.cronTime, job.task, job.options); - } - } -}; diff --git a/src/logger.js b/src/logger.js deleted file mode 100644 index 06392df..0000000 --- a/src/logger.js +++ /dev/null @@ -1,63 +0,0 @@ - -import log4js from "log4js"; - -// 日志目录可通过环境变量 LOG_DIR 配置,默认 logs -const LOG_DIR = process.env.LOG_DIR || "logs"; - -log4js.configure({ - appenders: { - all: { - type: "file", - filename: `${LOG_DIR}/all.log`, - maxLogSize: 102400, - pattern: "-yyyy-MM-dd.log", - alwaysIncludePattern: true, - backups: 3, - layout: { - type: 'pattern', - pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', - }, - }, - error: { - type: "file", - filename: `${LOG_DIR}/error.log`, - maxLogSize: 102400, - pattern: "-yyyy-MM-dd.log", - alwaysIncludePattern: true, - backups: 3, - layout: { - type: 'pattern', - pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', - }, - }, - jobs: { - type: "file", - filename: `${LOG_DIR}/jobs.log`, - maxLogSize: 102400, - pattern: "-yyyy-MM-dd.log", - alwaysIncludePattern: true, - backups: 3, - layout: { - type: 'pattern', - pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', - }, - }, - console: { - type: "console", - layout: { - type: "pattern", - pattern: '\x1b[36m[%d{yyyy-MM-dd hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m', - }, - }, - }, - categories: { - jobs: { appenders: ["console", "jobs"], level: "info" }, - error: { appenders: ["console", "error"], level: "error" }, - default: { appenders: ["console", "all", "error"], level: "all" }, - }, -}); - -// 导出常用 logger 实例,便于直接引用 -export const logger = log4js.getLogger(); // default -export const jobLogger = log4js.getLogger('jobs'); -export const errorLogger = log4js.getLogger('error'); diff --git a/src/main.js b/src/main.js index 7f27c89..39de094 100644 --- a/src/main.js +++ b/src/main.js @@ -1,41 +1,52 @@ -import { app } from "./global" +import { app } from "./app/bootstrap/app.js" // 日志、全局插件、定时任务等基础设施 -import { logger } from "./logger.js" -import "./jobs/index.js" +import { logger } from "./app/bootstrap/logger.js" +import "./infrastructure/jobs/index.js" // 第三方依赖 import os from "os" // 应用插件与自动路由 -import LoadMiddlewares from "./middlewares/install.js" +import LoadMiddlewares from "./presentation/middlewares/install.js" -// 注册插件 -LoadMiddlewares(app) +// 异步启动函数 +async function startServer() { + // 注册插件 + await LoadMiddlewares(app) -const PORT = process.env.PORT || 3000 + const PORT = process.env.PORT || 3000 -const server = app.listen(PORT, () => { - const port = server.address().port - // 获取本地 IP - const getLocalIP = () => { - const interfaces = os.networkInterfaces() - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name]) { - if (iface.family === "IPv4" && !iface.internal) { - return iface.address + const server = app.listen(PORT, () => { + const port = server.address().port + // 获取本地 IP + const getLocalIP = () => { + const interfaces = os.networkInterfaces() + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address + } } } + return "localhost" } - return "localhost" - } - const localIP = getLocalIP() - logger.trace(`──────────────────── 服务器已启动 ────────────────────`) - logger.trace(` `) - logger.trace(` 本地访问: http://localhost:${port} `) - logger.trace(` 局域网: http://${localIP}:${port} `) - logger.trace(` `) - logger.trace(` 服务启动时间: ${new Date().toLocaleString()} `) - logger.trace(`──────────────────────────────────────────────────────\n`) + const localIP = getLocalIP() + logger.trace(`──────────────────── 服务器已启动 ────────────────────`) + logger.trace(` `) + logger.trace(` 本地访问: http://localhost:${port} `) + logger.trace(` 局域网: http://${localIP}:${port} `) + logger.trace(` `) + logger.trace(` 服务启动时间: ${new Date().toLocaleString()} `) + logger.trace(`──────────────────────────────────────────────────\n`) + }) + + return server +} + +// 启动服务器 +startServer().catch(error => { + logger.error('服务器启动失败:', error) + process.exit(1) }) -export default app +export default app \ No newline at end of file diff --git a/src/middlewares/Auth/auth.js b/src/middlewares/Auth/auth.js deleted file mode 100644 index 81bfc70..0000000 --- a/src/middlewares/Auth/auth.js +++ /dev/null @@ -1,73 +0,0 @@ -import { logger } from "@/logger" -import jwt from "./jwt" -import { minimatch } from "minimatch" - -export const JWT_SECRET = process.env.JWT_SECRET - -function matchList(list, path) { - for (const item of list) { - if (typeof item === "string" && minimatch(path, item)) { - return { matched: true, auth: false } - } - if (typeof item === "object" && minimatch(path, item.pattern)) { - return { matched: true, auth: item.auth } - } - } - return { matched: false } -} - -function verifyToken(ctx) { - let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "") - if (!token) { - return { ok: false, status: -1 } - } - try { - ctx.state.user = jwt.verify(token, JWT_SECRET) - return { ok: true } - } catch { - ctx.state.user = undefined - return { ok: false } - } -} - -export default function authMiddleware(options = { - whiteList: [], - blackList: [] -}) { - return async (ctx, next) => { - if(ctx.session.user) { - ctx.state.user = ctx.session.user - } - // 黑名单优先生效 - if (matchList(options.blackList, ctx.path).matched) { - ctx.status = 403 - ctx.body = { success: false, error: "禁止访问" } - return - } - // 白名单处理 - const white = matchList(options.whiteList, ctx.path) - if (white.matched) { - if (white.auth === false) { - return await next() - } - if (white.auth === "try") { - verifyToken(ctx) - return await next() - } - // true 或其他情况,必须有token - if (!verifyToken(ctx).ok) { - ctx.status = 401 - ctx.body = { success: false, error: "未登录或token缺失或无效" } - return - } - return await next() - } - // 非白名单,必须有token - if (!verifyToken(ctx).ok) { - ctx.status = 401 - ctx.body = { success: false, error: "未登录或token缺失或无效" } - return - } - await next() - } -} diff --git a/src/middlewares/Auth/index.js b/src/middlewares/Auth/index.js deleted file mode 100644 index bc43ac3..0000000 --- a/src/middlewares/Auth/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// 统一导出所有中间件 -import Auth from "./auth.js" -export { Auth } diff --git a/src/middlewares/Auth/jwt.js b/src/middlewares/Auth/jwt.js deleted file mode 100644 index 0af32e5..0000000 --- a/src/middlewares/Auth/jwt.js +++ /dev/null @@ -1,3 +0,0 @@ -// 兼容性导出,便于后续扩展 -import jwt from "jsonwebtoken" -export default jwt diff --git a/src/middlewares/ErrorHandler/index.js b/src/middlewares/ErrorHandler/index.js deleted file mode 100644 index 816dce4..0000000 --- a/src/middlewares/ErrorHandler/index.js +++ /dev/null @@ -1,43 +0,0 @@ -import { logger } from "@/logger" -// src/plugins/errorHandler.js -// 错误处理中间件插件 - -async function formatError(ctx, status, message, stack) { - const accept = ctx.accepts("json", "html", "text") - const isDev = process.env.NODE_ENV === "development" - if (accept === "json") { - ctx.type = "application/json" - ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } - } else if (accept === "html") { - ctx.type = "html" - await ctx.render("error/index", { status, message, stack, isDev }) - } else { - ctx.type = "text" - ctx.body = isDev && stack ? `${status} - ${message}\n${stack}` : `${status} - ${message}` - } - ctx.status = status -} - -export default function errorHandler() { - return async (ctx, next) => { - // 拦截 Chrome DevTools 探测请求,直接返回 204 - if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { - ctx.status = 204 - ctx.body = "" - return - } - try { - await next() - if (ctx.status === 404) { - await formatError(ctx, 404, "Resource not found") - } - } catch (err) { - logger.error(err) - const isDev = process.env.NODE_ENV === "development" - if (isDev && err.stack) { - console.error(err.stack) - } - await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) - } - } -} diff --git a/src/middlewares/ResponseTime/index.js b/src/middlewares/ResponseTime/index.js deleted file mode 100644 index 8312814..0000000 --- a/src/middlewares/ResponseTime/index.js +++ /dev/null @@ -1,63 +0,0 @@ -import { logger } from "@/logger" - -// 静态资源扩展名列表 -const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] - -function isStaticResource(path) { - return staticExts.some(ext => path.endsWith(ext)) -} - -/** - * 响应时间记录中间件 - * @param {Object} ctx - Koa上下文对象 - * @param {Function} next - Koa中间件链函数 - */ -export default async (ctx, next) => { - if (isStaticResource(ctx.path)) { - await next() - return - } - if (!ctx.path.includes("/api")) { - const start = Date.now() - await next() - const ms = Date.now() - start - ctx.set("X-Response-Time", `${ms}ms`) - if (ms > 500) { - logger.info(`${ctx.path} | ⏱️ ${ms}ms`) - } - return - } - // API日志记录 - const start = Date.now() - await next() - const ms = Date.now() - start - ctx.set("X-Response-Time", `${ms}ms`) - const Threshold = 0 - if (ms > Threshold) { - logger.info("====================[➡️REQ]====================") - // 用户信息(假设ctx.state.user存在) - const user = ctx.state && ctx.state.user ? ctx.state.user : null - // IP - const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress - // 请求参数 - const params = { - query: ctx.query, - body: ctx.request.body, - } - // 响应状态码 - const status = ctx.status - // 组装日志对象 - const logObj = { - method: ctx.method, - path: ctx.path, - url: ctx.url, - user: user ? { id: user.id, username: user.username } : null, - ip, - params, - status, - ms, - } - logger.info(JSON.stringify(logObj, null, 2)) - logger.info("====================[⬅️END]====================\n") - } -} diff --git a/src/middlewares/Send/index.js b/src/middlewares/Send/index.js deleted file mode 100644 index 1502d3f..0000000 --- a/src/middlewares/Send/index.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * koa-send@5.0.1 转换为ES Module版本 - * 静态资源服务中间件 - */ -import fs from 'fs'; -import { promisify } from 'util'; -import logger from 'log4js'; -import resolvePath from './resolve-path.js'; -import createError from 'http-errors'; -import assert from 'assert'; -import { normalize, basename, extname, resolve, parse, sep } from 'path'; -import { fileURLToPath } from 'url'; -import path from "path" - -// 转换为ES Module格式 -const log = logger.getLogger('koa-send'); -const stat = promisify(fs.stat); -const access = promisify(fs.access); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -/** - * 检查文件是否存在 - * @param {string} path - 文件路径 - * @returns {Promise} 文件是否存在 - */ -async function exists(path) { - try { - await access(path); - return true; - } catch (e) { - return false; - } -} - -/** - * 发送文件给客户端 - * @param {Context} ctx - Koa上下文对象 - * @param {String} path - 文件路径 - * @param {Object} [opts] - 配置选项 - * @returns {Promise} - 异步Promise - */ -async function send(ctx, path, opts = {}) { - assert(ctx, 'koa context required'); - assert(path, 'pathname required'); - - // 移除硬编码的public目录,要求必须通过opts.root配置 - const root = opts.root; - if (!root) { - throw new Error('Static root directory must be configured via opts.root'); - } - const trailingSlash = path[path.length - 1] === '/'; - path = path.substr(parse(path).root.length); - const index = opts.index || 'index.html'; - const maxage = opts.maxage || opts.maxAge || 0; - const immutable = opts.immutable || false; - const hidden = opts.hidden || false; - const format = opts.format !== false; - const extensions = Array.isArray(opts.extensions) ? opts.extensions : false; - const brotli = opts.brotli !== false; - const gzip = opts.gzip !== false; - const setHeaders = opts.setHeaders; - - if (setHeaders && typeof setHeaders !== 'function') { - throw new TypeError('option setHeaders must be function'); - } - - // 解码路径 - path = decode(path); - if (path === -1) return ctx.throw(400, 'failed to decode'); - - // 索引文件支持 - if (index && trailingSlash) path += index; - - path = resolvePath(root, path); - - // 隐藏文件支持 - if (!hidden && isHidden(root, path)) return; - - let encodingExt = ''; - // 尝试提供压缩文件 - if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) { - path = path + '.br'; - ctx.set('Content-Encoding', 'br'); - ctx.res.removeHeader('Content-Length'); - encodingExt = '.br'; - } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) { - path = path + '.gz'; - ctx.set('Content-Encoding', 'gzip'); - ctx.res.removeHeader('Content-Length'); - encodingExt = '.gz'; - } - - // 尝试添加文件扩展名 - if (extensions && !/\./.exec(basename(path))) { - const list = [].concat(extensions); - for (let i = 0; i < list.length; i++) { - let ext = list[i]; - if (typeof ext !== 'string') { - throw new TypeError('option extensions must be array of strings or false'); - } - if (!/^\./.exec(ext)) ext = `.${ext}`; - if (await exists(`${path}${ext}`)) { - path = `${path}${ext}`; - break; - } - } - } - - // 获取文件状态 - let stats; - try { - stats = await stat(path); - - // 处理目录 - if (stats.isDirectory()) { - if (format && index) { - path += `/${index}`; - stats = await stat(path); - } else { - return; - } - } - } catch (err) { - const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; - if (notfound.includes(err.code)) { - throw createError(404, err); - } - err.status = 500; - throw err; - } - - if (setHeaders) setHeaders(ctx.res, path, stats); - - // 设置响应头 - ctx.set('Content-Length', stats.size); - if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()); - if (!ctx.response.get('Cache-Control')) { - const directives = [`max-age=${(maxage / 1000) | 0}`]; - if (immutable) directives.push('immutable'); - ctx.set('Cache-Control', directives.join(',')); - } - if (!ctx.type) ctx.type = type(path, encodingExt); - ctx.body = fs.createReadStream(path); - - return path; -} - -/** - * 检查是否为隐藏文件 - * @param {string} root - 根目录 - * @param {string} path - 文件路径 - * @returns {boolean} 是否为隐藏文件 - */ -function isHidden(root, path) { - path = path.substr(root.length).split(sep); - for (let i = 0; i < path.length; i++) { - if (path[i][0] === '.') return true; - } - return false; -} - -/** - * 获取文件类型 - * @param {string} file - 文件路径 - * @param {string} ext - 编码扩展名 - * @returns {string} 文件MIME类型 - */ -function type(file, ext) { - return ext !== '' ? extname(basename(file, ext)) : extname(file); -} - -/** - * 解码URL路径 - * @param {string} path - 需要解码的路径 - * @returns {string|number} 解码后的路径或错误代码 - */ -function decode(path) { - try { - return decodeURIComponent(path); - } catch (err) { - return -1; - } -} - -export default send; diff --git a/src/middlewares/Send/resolve-path.js b/src/middlewares/Send/resolve-path.js deleted file mode 100644 index 9c6dce6..0000000 --- a/src/middlewares/Send/resolve-path.js +++ /dev/null @@ -1,74 +0,0 @@ -/*! - * resolve-path - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015-2018 Douglas Christopher Wilson - * MIT Licensed - */ - -/** - * ES Module 转换版本 - * 路径解析工具,防止路径遍历攻击 - */ -import createError from 'http-errors'; -import { join, normalize, resolve, sep } from 'path'; -import pathIsAbsolute from 'path-is-absolute'; - -/** - * 模块变量 - * @private - */ -const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/; - -/** - * 解析相对路径到根路径 - * @param {string} rootPath - 根目录路径 - * @param {string} relativePath - 相对路径 - * @returns {string} 解析后的绝对路径 - * @public - */ -function resolvePath(rootPath, relativePath) { - let path = relativePath; - let root = rootPath; - - // root是可选的,类似于root.resolve - if (arguments.length === 1) { - path = rootPath; - root = process.cwd(); - } - - if (root == null) { - throw new TypeError('argument rootPath is required'); - } - - if (typeof root !== 'string') { - throw new TypeError('argument rootPath must be a string'); - } - - if (path == null) { - throw new TypeError('argument relativePath is required'); - } - - if (typeof path !== 'string') { - throw new TypeError('argument relativePath must be a string'); - } - - // 包含NULL字节是恶意的 - if (path.indexOf('\0') !== -1) { - throw createError(400, 'Malicious Path'); - } - - // 路径绝不能是绝对路径 - if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) { - throw createError(400, 'Malicious Path'); - } - - // 路径超出根目录 - if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) { - throw createError(403); - } - - // 拼接相对路径 - return normalize(join(resolve(root), path)); -} - -export default resolvePath; diff --git a/src/middlewares/Session/index.js b/src/middlewares/Session/index.js deleted file mode 100644 index 47da2a2..0000000 --- a/src/middlewares/Session/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import session from 'koa-session'; - -export default (app) => { - const CONFIG = { - key: 'koa:sess', // cookie key - maxAge: 86400000, // 1天 - httpOnly: true, - signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys - rolling: false, - renew: false, - secure: process.env.NODE_ENV === "production" && process.env.HTTPS_ENABLE === "on", - sameSite: "strict", // https://scotthelme.co.uk/csrf-is-dead/ - }; - return session(CONFIG, app); -}; diff --git a/src/middlewares/Toast/index.js b/src/middlewares/Toast/index.js deleted file mode 100644 index ad7a05c..0000000 --- a/src/middlewares/Toast/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export default function ToastMiddlewares() { - return function toast(ctx, next) { - if (ctx.toast) return next() - // error success info - ctx.toast = function (type, message) { - ctx.cookies.set("toast", JSON.stringify({ type: type, message: encodeURIComponent(message) }), { - maxAge: 1, - httpOnly: false, - path: "/", - }) - } - return next() - } -} diff --git a/src/middlewares/Views/index.js b/src/middlewares/Views/index.js deleted file mode 100644 index 8250bf6..0000000 --- a/src/middlewares/Views/index.js +++ /dev/null @@ -1,76 +0,0 @@ -import { resolve } from "path" -import { app } from "@/global" -import consolidate from "consolidate" -import send from "../Send" -import getPaths from "get-paths" -// import pretty from "pretty" -import { logger } from "@/logger" -import SiteConfigService from "services/SiteConfigService.js" -import assign from "lodash/assign" -import config from "config/index.js" - -export default viewsMiddleware - -function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) { - const siteConfigService = new SiteConfigService() - - return async function views(ctx, next) { - if (ctx.render) return await next() - - // 将 render 注入到 context 和 response 对象中 - ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) { - renderOptions = assign({ includeSite: true, includeUser: false }, renderOptions || {}) - return getPaths(path, relPath, extension).then(async paths => { - const suffix = paths.ext - const site = await siteConfigService.getAll() - const otherData = { - currentPath: ctx.path, - $config: config, - isLogin: !!ctx.state && !!ctx.state.user, - } - if (renderOptions.includeSite) { - otherData.$site = site - } - if (renderOptions.includeUser && ctx.state && ctx.state.user) { - otherData.$user = ctx.state.user - } - const state = assign({}, otherData, locals, options, ctx.state || {}) - // deep copy partials - state.partials = assign({}, options.partials || {}) - // logger.debug("render `%s` with %j", paths.rel, state) - ctx.type = "text/html" - - // 如果是 html 文件,不编译直接 send 静态文件 - if (isHtml(suffix) && !map) { - return send(ctx, paths.rel, { - root: path, - }) - } else { - const engineName = map && map[suffix] ? map[suffix] : suffix - - // 使用 engineSource 配置的渲染引擎 render - const render = engineSource[engineName] - - if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`)) - - return render(resolve(path, paths.rel), state).then(html => { - // since pug has deprecated `pretty` option - // we'll use the `pretty` package in the meanwhile - // if (locals.pretty) { - // debug("using `pretty` package to beautify HTML") - // html = pretty(html) - // } - ctx.body = html - }) - } - }) - } - - // 中间件执行结束 - return await next() - } -} - -function isHtml(ext) { - return ext === "html" -} diff --git a/src/middlewares/errorHandler/index.js b/src/middlewares/errorHandler/index.js deleted file mode 100644 index 816dce4..0000000 --- a/src/middlewares/errorHandler/index.js +++ /dev/null @@ -1,43 +0,0 @@ -import { logger } from "@/logger" -// src/plugins/errorHandler.js -// 错误处理中间件插件 - -async function formatError(ctx, status, message, stack) { - const accept = ctx.accepts("json", "html", "text") - const isDev = process.env.NODE_ENV === "development" - if (accept === "json") { - ctx.type = "application/json" - ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } - } else if (accept === "html") { - ctx.type = "html" - await ctx.render("error/index", { status, message, stack, isDev }) - } else { - ctx.type = "text" - ctx.body = isDev && stack ? `${status} - ${message}\n${stack}` : `${status} - ${message}` - } - ctx.status = status -} - -export default function errorHandler() { - return async (ctx, next) => { - // 拦截 Chrome DevTools 探测请求,直接返回 204 - if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { - ctx.status = 204 - ctx.body = "" - return - } - try { - await next() - if (ctx.status === 404) { - await formatError(ctx, 404, "Resource not found") - } - } catch (err) { - logger.error(err) - const isDev = process.env.NODE_ENV === "development" - if (isDev && err.stack) { - console.error(err.stack) - } - await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) - } - } -} diff --git a/src/middlewares/install.js b/src/middlewares/install.js deleted file mode 100644 index 0f90e83..0000000 --- a/src/middlewares/install.js +++ /dev/null @@ -1,69 +0,0 @@ -import ResponseTime from "./ResponseTime" -import Send from "./Send" -import { resolve } from "path" -import { fileURLToPath } from "url" -import path from "path" -import ErrorHandler from "./ErrorHandler" -import { Auth } from "./Auth" -import bodyParser from "koa-bodyparser" -import Views from "./Views" -import Session from "./Session" -import etag from "@koa/etag" -import conditional from "koa-conditional-get" -import { autoRegisterControllers } from "@/utils/ForRegister.js" - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const publicPath = resolve(__dirname, "../../public") - -export default app => { - // 错误处理 - app.use(ErrorHandler()) - // 响应时间 - app.use(ResponseTime) - // session设置 - app.use(Session(app)) - // 权限设置 - app.use( - Auth({ - whiteList: [ - // 所有请求放行 - { pattern: "/", auth: false }, - { pattern: "/**/*", auth: false }, - ], - blackList: [ - // 禁用api请求 - // "/api", - // "/api/", - // "/api/**/*", - ], - }) - ) - // 视图设置 - app.use( - Views(resolve(__dirname, "../views"), { - extension: "pug", - options: { - basedir: resolve(__dirname, "../views"), - }, - }) - ) - // 请求体解析 - app.use(bodyParser()) - // 自动注册控制器 - autoRegisterControllers(app, path.resolve(__dirname, "../controllers")) - // 注册完成之后静态资源设置 - app.use(async (ctx, next) => { - if (ctx.body) return await next() - if (ctx.status === 200) return await next() - if (ctx.method.toLowerCase() === "get") { - try { - await Send(ctx, ctx.path, { root: publicPath, maxAge: 0, immutable: false }) - } catch (err) { - if (err.status !== 404) throw err - } - } - await next() - }) - app.use(conditional()) - app.use(etag()) -} diff --git a/src/modules/article/controllers/ArticleController.js b/src/modules/article/controllers/ArticleController.js new file mode 100644 index 0000000..94f5bd4 --- /dev/null +++ b/src/modules/article/controllers/ArticleController.js @@ -0,0 +1,130 @@ +import { ArticleModel } from "../models/ArticleModel.js" +import Router from "../../../shared/utils/router.js" +import { marked } from "marked" + +class ArticleController { + async index(ctx) { + const { page = 1, view = 'grid' } = ctx.query + const limit = 12 // 每页显示的文章数量 + const offset = (page - 1) * limit + + // 获取文章总数 + const total = await ArticleModel.getPublishedArticleCount() + const totalPages = Math.ceil(total / limit) + + // 获取分页文章 + const articles = await ArticleModel.findPublished(offset, limit) + + // 获取所有分类和标签 + const categories = await ArticleModel.getArticleCountByCategory() + const allArticles = await ArticleModel.findPublished() + const tags = new Set() + allArticles.forEach(article => { + if (article.tags) { + article.tags.split(',').forEach(tag => { + tags.add(tag.trim()) + }) + } + }) + + return ctx.render("page/articles/index", { + articles, + categories: categories.map(c => c.category), + tags: Array.from(tags), + currentPage: parseInt(page), + totalPages, + view, + title: "文章列表", + }, { + includeUser: true, + includeSite: true, + }) + } + + async show(ctx) { + const { slug } = ctx.params + console.log(slug); + + const article = await ArticleModel.findBySlug(slug) + + if (!article) { + ctx.throw(404, "文章不存在") + } + + // 增加阅读次数 + await ArticleModel.incrementViewCount(article.id) + + // 将文章内容解析为HTML + article.content = marked(article.content || '') + + // 获取相关文章 + const relatedArticles = await ArticleModel.getRelatedArticles(article.id) + + return ctx.render("page/articles/article", { + article, + relatedArticles, + title: article.title, + }, { + includeUser: true, + }) + } + + async byCategory(ctx) { + const { category } = ctx.params + const articles = await ArticleModel.findByCategory(category) + + return ctx.render("page/articles/category", { + articles, + category, + title: `${category} - 分类文章`, + }, { + includeUser: true, + }) + } + + async byTag(ctx) { + const { tag } = ctx.params + const articles = await ArticleModel.findByTags(tag) + + return ctx.render("page/articles/tag", { + articles, + tag, + title: `${tag} - 标签文章`, + }, { + includeUser: true, + }) + } + + async search(ctx) { + const { q } = ctx.query + + if(!q) { + return ctx.set('hx-redirect', '/articles') + } + + const articles = await ArticleModel.searchByKeyword(q) + + return ctx.render("page/articles/search", { + articles, + keyword: q, + title: `搜索:${q}`, + }, { + includeUser: true, + }) + } + + static createRoutes() { + const controller = new ArticleController() + const router = new Router({ auth: true, prefix: "/articles" }) + router.get("", controller.index, { auth: false }) // 允许未登录访问 + router.get("/", controller.index, { auth: false }) // 允许未登录访问 + router.get("/search", controller.search, { auth: false }) + router.get("/category/:category", controller.byCategory) + router.get("/tag/:tag", controller.byTag) + router.get("/:slug", controller.show) + return router + } +} + +export default ArticleController +export { ArticleController } diff --git a/src/modules/article/models/ArticleModel.js b/src/modules/article/models/ArticleModel.js new file mode 100644 index 0000000..a635334 --- /dev/null +++ b/src/modules/article/models/ArticleModel.js @@ -0,0 +1,290 @@ +import db from "../../../infrastructure/database/index.js" + +class ArticleModel { + static async findAll() { + return db("articles").orderBy("created_at", "desc") + } + + static async findPublished(offset, limit) { + let query = db("articles") + .where("status", "published") + .whereNotNull("published_at") + .orderBy("published_at", "desc") + if (typeof offset === "number") { + query = query.offset(offset) + } + if (typeof limit === "number") { + query = query.limit(limit) + } + return query + } + + static async findDrafts() { + return db("articles").where("status", "draft").orderBy("updated_at", "desc") + } + + static async findById(id) { + return db("articles").where("id", id).first() + } + + static async findBySlug(slug) { + return db("articles").where("slug", slug).first() + } + + static async findByAuthor(author) { + return db("articles").where("author", author).where("status", "published").orderBy("published_at", "desc") + } + + static async findByCategory(category) { + return db("articles").where("category", category).where("status", "published").orderBy("published_at", "desc") + } + + static async findByTags(tags) { + // 支持多个标签搜索,标签以逗号分隔 + const tagArray = tags.split(",").map(tag => tag.trim()) + return db("articles") + .where("status", "published") + .whereRaw("tags LIKE ?", [`%${tagArray[0]}%`]) + .orderBy("published_at", "desc") + } + + static async searchByKeyword(keyword) { + return db("articles") + .where("status", "published") + .where(function () { + this.where("title", "like", `%${keyword}%`) + .orWhere("content", "like", `%${keyword}%`) + .orWhere("keywords", "like", `%${keyword}%`) + .orWhere("description", "like", `%${keyword}%`) + .orWhere("excerpt", "like", `%${keyword}%`) + }) + .orderBy("published_at", "desc") + } + + static async create(data) { + // 验证必填字段 + if (!data.title || !data.content) { + throw new Error("标题和内容为必填字段") + } + + // 处理标签,确保格式一致 + let tags = data.tags + if (tags && typeof tags === "string") { + tags = tags + .split(",") + .map(tag => tag.trim()) + .filter(tag => tag) + .join(", ") + } + + // 生成slug(如果未提供) + let slug = data.slug + if (!slug) { + slug = this.generateSlug(data.title) + } + + // 计算阅读时间(如果未提供) + let readingTime = data.reading_time + if (!readingTime) { + readingTime = this.calculateReadingTime(data.content) + } + + // 生成摘要(如果未提供) + let excerpt = data.excerpt + if (!excerpt && data.content) { + excerpt = this.generateExcerpt(data.content) + } + + return db("articles") + .insert({ + ...data, + tags, + slug, + reading_time: readingTime, + excerpt, + status: data.status || "draft", + view_count: 0, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning("*") + } + + static async update(id, data) { + const current = await db("articles").where("id", id).first() + if (!current) { + throw new Error("文章不存在") + } + + // 处理标签,确保格式一致 + let tags = data.tags + if (tags && typeof tags === "string") { + tags = tags + .split(",") + .map(tag => tag.trim()) + .filter(tag => tag) + .join(", ") + } + + // 生成slug(如果标题改变且未提供slug) + let slug = data.slug + if (data.title && data.title !== current.title && !slug) { + slug = this.generateSlug(data.title) + } + + // 计算阅读时间(如果内容改变且未提供) + let readingTime = data.reading_time + if (data.content && data.content !== current.content && !readingTime) { + readingTime = this.calculateReadingTime(data.content) + } + + // 生成摘要(如果内容改变且未提供) + let excerpt = data.excerpt + if (data.content && data.content !== current.content && !excerpt) { + excerpt = this.generateExcerpt(data.content) + } + + // 如果状态改为published,设置发布时间 + let publishedAt = data.published_at + if (data.status === "published" && current.status !== "published" && !publishedAt) { + publishedAt = db.fn.now() + } + + return db("articles") + .where("id", id) + .update({ + ...data, + tags: tags || current.tags, + slug: slug || current.slug, + reading_time: readingTime || current.reading_time, + excerpt: excerpt || current.excerpt, + published_at: publishedAt || current.published_at, + updated_at: db.fn.now(), + }) + .returning("*") + } + + static async delete(id) { + const article = await db("articles").where("id", id).first() + if (!article) { + throw new Error("文章不存在") + } + return db("articles").where("id", id).del() + } + + static async publish(id) { + return db("articles") + .where("id", id) + .update({ + status: "published", + published_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning("*") + } + + static async unpublish(id) { + return db("articles") + .where("id", id) + .update({ + status: "draft", + published_at: null, + updated_at: db.fn.now(), + }) + .returning("*") + } + + static async incrementViewCount(id) { + return db("articles").where("id", id).increment("view_count", 1).returning("*") + } + + static async findByDateRange(startDate, endDate) { + return db("articles") + .where("status", "published") + .whereBetween("published_at", [startDate, endDate]) + .orderBy("published_at", "desc") + } + + static async getArticleCount() { + const result = await db("articles").count("id as count").first() + return result ? result.count : 0 + } + + static async getPublishedArticleCount() { + const result = await db("articles").where("status", "published").count("id as count").first() + return result ? result.count : 0 + } + + static async getArticleCountByCategory() { + return db("articles") + .select("category") + .count("id as count") + .where("status", "published") + .groupBy("category") + .orderBy("count", "desc") + } + + static async getArticleCountByStatus() { + return db("articles").select("status").count("id as count").groupBy("status").orderBy("count", "desc") + } + + static async getRecentArticles(limit = 10) { + return db("articles").where("status", "published").orderBy("published_at", "desc").limit(limit) + } + + static async getPopularArticles(limit = 10) { + return db("articles").where("status", "published").orderBy("view_count", "desc").limit(limit) + } + + static async getFeaturedArticles(limit = 5) { + return db("articles").where("status", "published").whereNotNull("featured_image").orderBy("published_at", "desc").limit(limit) + } + + static async getRelatedArticles(articleId, limit = 5) { + const current = await this.findById(articleId) + if (!current) return [] + + return db("articles") + .where("status", "published") + .where("id", "!=", articleId) + .where(function () { + if (current.category) { + this.orWhere("category", current.category) + } + if (current.tags) { + const tags = current.tags.split(",").map(tag => tag.trim()) + tags.forEach(tag => { + this.orWhereRaw("tags LIKE ?", [`%${tag}%`]) + }) + } + }) + .orderBy("published_at", "desc") + .limit(limit) + } + + // 工具方法 + static generateSlug(title) { + return title + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim() + } + + static calculateReadingTime(content) { + // 假设平均阅读速度为每分钟200个单词 + const wordCount = content.split(/\s+/).length + return Math.ceil(wordCount / 200) + } + + static generateExcerpt(content, maxLength = 150) { + if (content.length <= maxLength) { + return content + } + return content.substring(0, maxLength).trim() + "..." + } +} + +export default ArticleModel +export { ArticleModel } diff --git a/src/modules/article/services/ArticleService.js b/src/modules/article/services/ArticleService.js new file mode 100644 index 0000000..df96e91 --- /dev/null +++ b/src/modules/article/services/ArticleService.js @@ -0,0 +1,295 @@ +import ArticleModel from "../models/ArticleModel.js" +import CommonError from "../../../shared/utils/error/CommonError.js" + +class ArticleService { + // 获取所有文章 + async getAllArticles() { + try { + return await ArticleModel.findAll() + } catch (error) { + throw new CommonError(`获取文章列表失败: ${error.message}`) + } + } + + // 获取已发布的文章 + async getPublishedArticles() { + try { + return await ArticleModel.findPublished() + } catch (error) { + throw new CommonError(`获取已发布文章失败: ${error.message}`) + } + } + + // 获取草稿文章 + async getDraftArticles() { + try { + return await ArticleModel.findDrafts() + } catch (error) { + throw new CommonError(`获取草稿文章失败: ${error.message}`) + } + } + + // 根据ID获取文章 + async getArticleById(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + return article + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取文章失败: ${error.message}`) + } + } + + // 根据slug获取文章 + async getArticleBySlug(slug) { + try { + const article = await ArticleModel.findBySlug(slug) + if (!article) { + throw new CommonError("文章不存在") + } + return article + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取文章失败: ${error.message}`) + } + } + + // 根据作者获取文章 + async getArticlesByAuthor(author) { + try { + return await ArticleModel.findByAuthor(author) + } catch (error) { + throw new CommonError(`获取作者文章失败: ${error.message}`) + } + } + + // 根据分类获取文章 + async getArticlesByCategory(category) { + try { + return await ArticleModel.findByCategory(category) + } catch (error) { + throw new CommonError(`获取分类文章失败: ${error.message}`) + } + } + + // 根据标签获取文章 + async getArticlesByTags(tags) { + try { + return await ArticleModel.findByTags(tags) + } catch (error) { + throw new CommonError(`获取标签文章失败: ${error.message}`) + } + } + + // 关键词搜索文章 + async searchArticles(keyword) { + try { + if (!keyword || keyword.trim() === '') { + throw new CommonError("搜索关键词不能为空") + } + return await ArticleModel.searchByKeyword(keyword.trim()) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`搜索文章失败: ${error.message}`) + } + } + + // 创建文章 + async createArticle(data) { + try { + if (!data.title || !data.content) { + throw new CommonError("标题和内容为必填字段") + } + return await ArticleModel.create(data) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`创建文章失败: ${error.message}`) + } + } + + // 更新文章 + async updateArticle(id, data) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + return await ArticleModel.update(id, data) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`更新文章失败: ${error.message}`) + } + } + + // 删除文章 + async deleteArticle(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + return await ArticleModel.delete(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`删除文章失败: ${error.message}`) + } + } + + // 发布文章 + async publishArticle(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + if (article.status === 'published') { + throw new CommonError("文章已经是发布状态") + } + return await ArticleModel.publish(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`发布文章失败: ${error.message}`) + } + } + + // 取消发布文章 + async unpublishArticle(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + if (article.status === 'draft') { + throw new CommonError("文章已经是草稿状态") + } + return await ArticleModel.unpublish(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`取消发布文章失败: ${error.message}`) + } + } + + // 增加文章阅读量 + async incrementViewCount(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new CommonError("文章不存在") + } + return await ArticleModel.incrementViewCount(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`增加阅读量失败: ${error.message}`) + } + } + + // 根据日期范围获取文章 + async getArticlesByDateRange(startDate, endDate) { + try { + if (!startDate || !endDate) { + throw new CommonError("开始日期和结束日期不能为空") + } + return await ArticleModel.findByDateRange(startDate, endDate) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取日期范围文章失败: ${error.message}`) + } + } + + // 获取文章统计信息 + async getArticleStats() { + try { + const [totalCount, publishedCount, categoryStats, statusStats] = await Promise.all([ + ArticleModel.getArticleCount(), + ArticleModel.getPublishedArticleCount(), + ArticleModel.getArticleCountByCategory(), + ArticleModel.getArticleCountByStatus() + ]) + + return { + total: totalCount, + published: publishedCount, + draft: totalCount - publishedCount, + byCategory: categoryStats, + byStatus: statusStats + } + } catch (error) { + throw new CommonError(`获取文章统计失败: ${error.message}`) + } + } + + // 获取最近文章 + async getRecentArticles(limit = 10) { + try { + return await ArticleModel.getRecentArticles(limit) + } catch (error) { + throw new CommonError(`获取最近文章失败: ${error.message}`) + } + } + + // 获取热门文章 + async getPopularArticles(limit = 10) { + try { + return await ArticleModel.getPopularArticles(limit) + } catch (error) { + throw new CommonError(`获取热门文章失败: ${error.message}`) + } + } + + // 获取精选文章 + async getFeaturedArticles(limit = 5) { + try { + return await ArticleModel.getFeaturedArticles(limit) + } catch (error) { + throw new CommonError(`获取精选文章失败: ${error.message}`) + } + } + + // 获取相关文章 + async getRelatedArticles(articleId, limit = 5) { + try { + const article = await ArticleModel.findById(articleId) + if (!article) { + throw new CommonError("文章不存在") + } + return await ArticleModel.getRelatedArticles(articleId, limit) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取相关文章失败: ${error.message}`) + } + } + + // 分页获取文章 + async getArticlesWithPagination(page = 1, pageSize = 10, status = 'published') { + try { + let query = ArticleModel.findPublished() + if (status === 'all') { + query = ArticleModel.findAll() + } else if (status === 'draft') { + query = ArticleModel.findDrafts() + } + + const offset = (page - 1) * pageSize + const articles = await query.limit(pageSize).offset(offset) + const total = await ArticleModel.getPublishedArticleCount() + + return { + articles, + pagination: { + current: page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize) + } + } + } catch (error) { + throw new CommonError(`分页获取文章失败: ${error.message}`) + } + } +} + +export default ArticleService +export { ArticleService } diff --git a/src/modules/auth/controllers/AuthController.js b/src/modules/auth/controllers/AuthController.js new file mode 100644 index 0000000..1e72e3b --- /dev/null +++ b/src/modules/auth/controllers/AuthController.js @@ -0,0 +1,45 @@ +import UserService from "../services/userService.js" +import { R } from "../../../shared/utils/helper.js" +import Router from "../../../shared/utils/router.js" + +class AuthController { + constructor() { + this.userService = new UserService() + } + + async hello(ctx) { + R.ResponseJSON(R.SUCCESS,"Hello World") + } + + async getUser(ctx) { + const user = await this.userService.getUserById(ctx.params.id) + R.ResponseJSON(R.SUCCESS,user) + } + + async register(ctx) { + const { username, email, password } = ctx.request.body + const user = await this.userService.register({ username, email, password }) + R.ResponseJSON(R.SUCCESS,user) + } + + async login(ctx) { + const { username, email, password } = ctx.request.body + const result = await this.userService.login({ username, email, password }) + R.ResponseJSON(R.SUCCESS,result) + } + + /** + * 路由注册 + */ + static createRoutes() { + const controller = new AuthController() + const router = new Router({ prefix: "/api" }) + router.get("/hello", controller.hello.bind(controller), { auth: false }) + router.get("/user/:id", controller.getUser.bind(controller)) + router.post("/register", controller.register.bind(controller)) + router.post("/login", controller.login.bind(controller)) + return router + } +} + +export default AuthController diff --git a/src/modules/auth/controllers/AuthPageController.js b/src/modules/auth/controllers/AuthPageController.js new file mode 100644 index 0000000..4b9780b --- /dev/null +++ b/src/modules/auth/controllers/AuthPageController.js @@ -0,0 +1,136 @@ +import Router from "../../../shared/utils/router.js" +import UserService from "../services/userService.js" +import svgCaptcha from "svg-captcha" +import CommonError from "../../../shared/utils/error/CommonError.js" +import { logger } from "../../../app/bootstrap/logger.js" + +/** + * 认证相关页面控制器 + * 负责处理登录、注册、验证码、登出等认证相关功能 + */ +class AuthPageController { + constructor() { + this.userService = new UserService() + } + + // 未授权报错页 + async indexNoAuth(ctx) { + return await ctx.render("page/auth/no-auth", {}) + } + + // 登录页 + async loginGet(ctx) { + if (ctx.session.user) { + ctx.status = 200 + ctx.redirect("/?msg=用户已登录") + return + } + return await ctx.render("page/login/index", { site_title: "登录" }) + } + + // 处理登录请求 + async loginPost(ctx) { + const { username, email, password } = ctx.request.body + const result = await this.userService.login({ username, email, password }) + ctx.session.user = result.user + ctx.body = { success: true, message: "登录成功" } + } + + // 获取验证码 + async captchaGet(ctx) { + var captcha = svgCaptcha.create({ + size: 4, // 个数 + width: 100, // 宽 + height: 30, // 高 + fontSize: 38, // 字体大小 + color: true, // 字体颜色是否多变 + noise: 4, // 干扰线几条 + }) + // 记录验证码信息(文本+过期时间) + // 这里设置5分钟后过期 + const expireTime = Date.now() + 5 * 60 * 1000 + ctx.session.captcha = { + text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证 + expireTime: expireTime, + } + ctx.type = "image/svg+xml" + ctx.body = captcha.data + } + + // 注册页 + async registerGet(ctx) { + if (ctx.session.user) { + return ctx.redirect("/?msg=用户已登录") + } + return await ctx.render("page/register/index", { site_title: "注册" }) + } + + // 处理注册请求 + async registerPost(ctx) { + const { username, password, code } = ctx.request.body + + // 检查Session中是否存在验证码 + if (!ctx.session.captcha) { + throw new CommonError("验证码不存在,请重新获取") + } + + const { text, expireTime } = ctx.session.captcha + + // 检查是否过期 + if (Date.now() > expireTime) { + // 过期后清除Session中的验证码 + delete ctx.session.captcha + throw new CommonError("验证码已过期,请重新获取") + } + + if (!code) { + throw new CommonError("请输入验证码") + } + + if (code.toLowerCase() !== text) { + throw new CommonError("验证码错误") + } + + delete ctx.session.captcha + + await this.userService.register({ username, name: username, password, role: "user" }) + return ctx.redirect("/login") + } + + // 退出登录 + async logout(ctx) { + ctx.status = 200 + delete ctx.session.user + ctx.set("hx-redirect", "/") + } + + /** + * 创建认证相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new AuthPageController() + const router = new Router({ auth: "try" }) + + // 未授权报错页 + router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) + + // 登录相关 + router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) + router.post("/login", controller.loginPost.bind(controller), { auth: false }) + + // 注册相关 + router.get("/register", controller.registerGet.bind(controller), { auth: "try" }) + router.post("/register", controller.registerPost.bind(controller), { auth: false }) + + // 验证码 + router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) + + // 登出 + router.post("/logout", controller.logout.bind(controller), { auth: true }) + + return router + } +} + +export default AuthPageController \ No newline at end of file diff --git a/src/modules/auth/models/UserModel.js b/src/modules/auth/models/UserModel.js new file mode 100644 index 0000000..ef8131c --- /dev/null +++ b/src/modules/auth/models/UserModel.js @@ -0,0 +1,36 @@ +import db from "../../../infrastructure/database/index.js" + +class UserModel { + static async findAll() { + return db("users").select("*") + } + + static async findById(id) { + return db("users").where("id", id).first() + } + + static async create(data) { + return db("users").insert({ + ...data, + updated_at: db.fn.now(), + }).returning("*") + } + + static async update(id, data) { + return db("users").where("id", id).update(data).returning("*") + } + + static async delete(id) { + return db("users").where("id", id).del() + } + + static async findByUsername(username) { + return db("users").where("username", username).first() + } + static async findByEmail(email) { + return db("users").where("email", email).first() + } +} + +export default UserModel +export { UserModel } diff --git a/src/modules/auth/services/userService.js b/src/modules/auth/services/userService.js new file mode 100644 index 0000000..de8d4c6 --- /dev/null +++ b/src/modules/auth/services/userService.js @@ -0,0 +1,414 @@ +import UserModel from "../models/UserModel.js" +import { hashPassword, comparePassword } from "../../../shared/utils/bcrypt.js" +import CommonError from "../../../shared/utils/error/CommonError.js" +import { JWT_SECRET } from "../../../presentation/middlewares/Auth/auth.js" +import jwt from "../../../presentation/middlewares/Auth/jwt.js" + +class UserService { + // 根据ID获取用户 + async getUserById(id) { + try { + if (!id) { + throw new CommonError("用户ID不能为空") + } + const user = await UserModel.findById(id) + if (!user) { + throw new CommonError("用户不存在") + } + // 返回脱敏信息 + const { password, ...userInfo } = user + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取用户失败: ${error.message}`) + } + } + + // 获取所有用户 + async getAllUsers() { + try { + const users = await UserModel.findAll() + // 返回脱敏信息 + return users.map(user => { + const { password, ...userInfo } = user + return userInfo + }) + } catch (error) { + throw new CommonError(`获取用户列表失败: ${error.message}`) + } + } + + // 创建新用户 + async createUser(data) { + try { + if (!data.username || !data.password) { + throw new CommonError("用户名和密码为必填字段") + } + + // 检查用户名是否已存在 + const existUser = await UserModel.findByUsername(data.username) + if (existUser) { + throw new CommonError(`用户名${data.username}已存在`) + } + + // 检查邮箱是否已存在 + if (data.email) { + const existEmail = await UserModel.findByEmail(data.email) + if (existEmail) { + throw new CommonError(`邮箱${data.email}已被使用`) + } + } + + // 密码加密 + const hashedPassword = await hashPassword(data.password) + + const user = await UserModel.create({ + ...data, + password: hashedPassword + }) + + // 返回脱敏信息 + const { password, ...userInfo } = Array.isArray(user) ? user[0] : user + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`创建用户失败: ${error.message}`) + } + } + + // 更新用户 + async updateUser(id, data) { + try { + if (!id) { + throw new CommonError("用户ID不能为空") + } + + const user = await UserModel.findById(id) + if (!user) { + throw new CommonError("用户不存在") + } + + // 如果要更新用户名,检查是否重复 + if (data.username && data.username !== user.username) { + const existUser = await UserModel.findByUsername(data.username) + if (existUser) { + throw new CommonError(`用户名${data.username}已存在`) + } + } + + // 如果要更新邮箱,检查是否重复 + if (data.email && data.email !== user.email) { + const existEmail = await UserModel.findByEmail(data.email) + if (existEmail) { + throw new CommonError(`邮箱${data.email}已被使用`) + } + } + + // 如果要更新密码,需要加密 + if (data.password) { + data.password = await hashPassword(data.password) + } + + const updatedUser = await UserModel.update(id, data) + + // 返回脱敏信息 + const { password, ...userInfo } = Array.isArray(updatedUser) ? updatedUser[0] : updatedUser + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`更新用户失败: ${error.message}`) + } + } + + // 删除用户 + async deleteUser(id) { + try { + if (!id) { + throw new CommonError("用户ID不能为空") + } + + const user = await UserModel.findById(id) + if (!user) { + throw new CommonError("用户不存在") + } + + return await UserModel.delete(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`删除用户失败: ${error.message}`) + } + } + + // 注册新用户 + async register(data) { + try { + if (!data.username || !data.password) { + throw new CommonError("用户名和密码不能为空") + } + + // 检查用户名是否已存在 + const existUser = await UserModel.findByUsername(data.username) + if (existUser) { + throw new CommonError(`用户名${data.username}已存在`) + } + + // 检查邮箱是否已存在 + if (data.email) { + const existEmail = await UserModel.findByEmail(data.email) + if (existEmail) { + throw new CommonError(`邮箱${data.email}已被使用`) + } + } + + // 密码加密 + const hashed = await hashPassword(data.password) + + const user = await UserModel.create({ ...data, password: hashed }) + + // 返回脱敏信息 + const { password, ...userInfo } = Array.isArray(user) ? user[0] : user + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`注册失败: ${error.message}`) + } + } + + // 登录 + async login({ username, email, password }) { + try { + if (!password) { + throw new CommonError("密码不能为空") + } + + if (!username && !email) { + throw new CommonError("用户名或邮箱不能为空") + } + + let user + if (username) { + user = await UserModel.findByUsername(username) + } else if (email) { + user = await UserModel.findByEmail(email) + } + + if (!user) { + throw new CommonError("用户不存在") + } + + // 校验密码 + const ok = await comparePassword(password, user.password) + if (!ok) { + throw new CommonError("密码错误") + } + + // 生成token + const token = jwt.sign( + { id: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: "2h" } + ) + + // 返回token和用户信息 + const { password: pwd, ...userInfo } = user + return { token, user: userInfo } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`登录失败: ${error.message}`) + } + } + + // 根据用户名查找用户 + async getUserByUsername(username) { + try { + if (!username) { + throw new CommonError("用户名不能为空") + } + + const user = await UserModel.findByUsername(username) + if (!user) { + throw new CommonError("用户不存在") + } + + // 返回脱敏信息 + const { password, ...userInfo } = user + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取用户失败: ${error.message}`) + } + } + + // 根据邮箱查找用户 + async getUserByEmail(email) { + try { + if (!email) { + throw new CommonError("邮箱不能为空") + } + + const user = await UserModel.findByEmail(email) + if (!user) { + throw new CommonError("用户不存在") + } + + // 返回脱敏信息 + const { password, ...userInfo } = user + return userInfo + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取用户失败: ${error.message}`) + } + } + + // 修改密码 + async changePassword(userId, oldPassword, newPassword) { + try { + if (!userId || !oldPassword || !newPassword) { + throw new CommonError("用户ID、旧密码和新密码不能为空") + } + + const user = await UserModel.findById(userId) + if (!user) { + throw new CommonError("用户不存在") + } + + // 验证旧密码 + const isOldPasswordCorrect = await comparePassword(oldPassword, user.password) + if (!isOldPasswordCorrect) { + throw new CommonError("旧密码错误") + } + + // 加密新密码 + const hashedNewPassword = await hashPassword(newPassword) + + // 更新密码 + await UserModel.update(userId, { password: hashedNewPassword }) + + return { message: "密码修改成功" } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`修改密码失败: ${error.message}`) + } + } + + // 重置密码 + async resetPassword(email, newPassword) { + try { + if (!email || !newPassword) { + throw new CommonError("邮箱和新密码不能为空") + } + + const user = await UserModel.findByEmail(email) + if (!user) { + throw new CommonError("用户不存在") + } + + // 加密新密码 + const hashedPassword = await hashPassword(newPassword) + + // 更新密码 + await UserModel.update(user.id, { password: hashedPassword }) + + return { message: "密码重置成功" } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`重置密码失败: ${error.message}`) + } + } + + // 获取用户统计信息 + async getUserStats() { + try { + const users = await UserModel.findAll() + + const stats = { + total: users.length, + active: users.filter(user => user.status === 'active').length, + inactive: users.filter(user => user.status === 'inactive').length, + byRole: {}, + byDate: {} + } + + // 按角色分组统计 + users.forEach(user => { + const role = user.role || 'user' + stats.byRole[role] = (stats.byRole[role] || 0) + 1 + }) + + // 按创建时间分组统计 + users.forEach(user => { + const date = new Date(user.created_at).toISOString().split('T')[0] + stats.byDate[date] = (stats.byDate[date] || 0) + 1 + }) + + return stats + } catch (error) { + throw new CommonError(`获取用户统计失败: ${error.message}`) + } + } + + // 搜索用户 + async searchUsers(keyword) { + try { + if (!keyword || keyword.trim() === '') { + return await this.getAllUsers() + } + + const users = await UserModel.findAll() + const searchTerm = keyword.toLowerCase().trim() + + const filteredUsers = users.filter(user => { + return ( + user.username?.toLowerCase().includes(searchTerm) || + user.email?.toLowerCase().includes(searchTerm) || + user.name?.toLowerCase().includes(searchTerm) + ) + }) + + // 返回脱敏信息 + return filteredUsers.map(user => { + const { password, ...userInfo } = user + return userInfo + }) + } catch (error) { + throw new CommonError(`搜索用户失败: ${error.message}`) + } + } + + // 批量删除用户 + async deleteUsers(userIds) { + try { + if (!Array.isArray(userIds) || userIds.length === 0) { + throw new CommonError("用户ID列表不能为空") + } + + const results = [] + const errors = [] + + for (const id of userIds) { + try { + await this.deleteUser(id) + results.push(id) + } catch (error) { + errors.push({ + id, + error: error.message + }) + } + } + + return { + success: results, + errors, + total: userIds.length, + successCount: results.length, + errorCount: errors.length + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`批量删除用户失败: ${error.message}`) + } + } +} + +export default UserService diff --git a/src/modules/bookmark/models/BookmarkModel.js b/src/modules/bookmark/models/BookmarkModel.js new file mode 100644 index 0000000..268bdc3 --- /dev/null +++ b/src/modules/bookmark/models/BookmarkModel.js @@ -0,0 +1,68 @@ +import db from "../../../infrastructure/database/index.js" + +class BookmarkModel { + static async findAllByUser(userId) { + return db("bookmarks").where("user_id", userId).orderBy("id", "desc") + } + + static async findById(id) { + return db("bookmarks").where("id", id).first() + } + + static async create(data) { + const userId = data.user_id + const url = typeof data.url === "string" ? data.url.trim() : data.url + + if (userId != null && url) { + const exists = await db("bookmarks").where({ user_id: userId, url }).first() + if (exists) { + throw new Error("该用户下已存在相同 URL 的书签") + } + } + + return db("bookmarks").insert({ + ...data, + url, + updated_at: db.fn.now(), + }).returning("*") + } + + static async update(id, data) { + // 若更新后 user_id 与 url 同时存在,则做排他性查重(排除自身) + const current = await db("bookmarks").where("id", id).first() + if (!current) return [] + + const nextUserId = data.user_id != null ? data.user_id : current.user_id + const nextUrlRaw = data.url != null ? data.url : current.url + const nextUrl = typeof nextUrlRaw === "string" ? nextUrlRaw.trim() : nextUrlRaw + + if (nextUserId != null && nextUrl) { + const exists = await db("bookmarks") + .where({ user_id: nextUserId, url: nextUrl }) + .andWhereNot({ id }) + .first() + if (exists) { + throw new Error("该用户下已存在相同 URL 的书签") + } + } + + return db("bookmarks").where("id", id).update({ + ...data, + url: data.url != null ? nextUrl : data.url, + updated_at: db.fn.now(), + }).returning("*") + } + + static async delete(id) { + return db("bookmarks").where("id", id).del() + } + + static async findByUserAndUrl(userId, url) { + return db("bookmarks").where({ user_id: userId, url }).first() + } +} + +export default BookmarkModel +export { BookmarkModel } + + diff --git a/src/modules/bookmark/services/BookmarkService.js b/src/modules/bookmark/services/BookmarkService.js new file mode 100644 index 0000000..b95e949 --- /dev/null +++ b/src/modules/bookmark/services/BookmarkService.js @@ -0,0 +1,312 @@ +import BookmarkModel from "../models/BookmarkModel.js" +import CommonError from "../../../shared/utils/error/CommonError.js" + +class BookmarkService { + // 获取用户的所有书签 + async getUserBookmarks(userId) { + try { + if (!userId) { + throw new CommonError("用户ID不能为空") + } + return await BookmarkModel.findAllByUser(userId) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取用户书签失败: ${error.message}`) + } + } + + // 根据ID获取书签 + async getBookmarkById(id) { + try { + if (!id) { + throw new CommonError("书签ID不能为空") + } + const bookmark = await BookmarkModel.findById(id) + if (!bookmark) { + throw new CommonError("书签不存在") + } + return bookmark + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取书签失败: ${error.message}`) + } + } + + // 创建书签 + async createBookmark(data) { + try { + if (!data.user_id || !data.url) { + throw new CommonError("用户ID和URL为必填字段") + } + + // 验证URL格式 + if (!this.isValidUrl(data.url)) { + throw new CommonError("URL格式不正确") + } + + return await BookmarkModel.create(data) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`创建书签失败: ${error.message}`) + } + } + + // 更新书签 + async updateBookmark(id, data) { + try { + if (!id) { + throw new CommonError("书签ID不能为空") + } + + const bookmark = await BookmarkModel.findById(id) + if (!bookmark) { + throw new CommonError("书签不存在") + } + + // 如果更新URL,验证格式 + if (data.url && !this.isValidUrl(data.url)) { + throw new CommonError("URL格式不正确") + } + + return await BookmarkModel.update(id, data) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`更新书签失败: ${error.message}`) + } + } + + // 删除书签 + async deleteBookmark(id) { + try { + if (!id) { + throw new CommonError("书签ID不能为空") + } + + const bookmark = await BookmarkModel.findById(id) + if (!bookmark) { + throw new CommonError("书签不存在") + } + + return await BookmarkModel.delete(id) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`删除书签失败: ${error.message}`) + } + } + + // 根据用户和URL查找书签 + async findBookmarkByUserAndUrl(userId, url) { + try { + if (!userId || !url) { + throw new CommonError("用户ID和URL不能为空") + } + + return await BookmarkModel.findByUserAndUrl(userId, url) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`查找书签失败: ${error.message}`) + } + } + + // 检查书签是否存在 + async isBookmarkExists(userId, url) { + try { + if (!userId || !url) { + return false + } + + const bookmark = await BookmarkModel.findByUserAndUrl(userId, url) + return !!bookmark + } catch (error) { + return false + } + } + + // 批量创建书签 + async createBookmarks(userId, bookmarksData) { + try { + if (!userId || !Array.isArray(bookmarksData) || bookmarksData.length === 0) { + throw new CommonError("用户ID和书签数据不能为空") + } + + const results = [] + const errors = [] + + for (const bookmarkData of bookmarksData) { + try { + const bookmark = await this.createBookmark({ + ...bookmarkData, + user_id: userId + }) + results.push(bookmark) + } catch (error) { + errors.push({ + url: bookmarkData.url, + error: error.message + }) + } + } + + return { + success: results, + errors, + total: bookmarksData.length, + successCount: results.length, + errorCount: errors.length + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`批量创建书签失败: ${error.message}`) + } + } + + // 批量删除书签 + async deleteBookmarks(userId, bookmarkIds) { + try { + if (!userId || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) { + throw new CommonError("用户ID和书签ID列表不能为空") + } + + const results = [] + const errors = [] + + for (const id of bookmarkIds) { + try { + const bookmark = await BookmarkModel.findById(id) + if (bookmark && bookmark.user_id === userId) { + await BookmarkModel.delete(id) + results.push(id) + } else { + errors.push({ + id, + error: "书签不存在或无权限删除" + }) + } + } catch (error) { + errors.push({ + id, + error: error.message + }) + } + } + + return { + success: results, + errors, + total: bookmarkIds.length, + successCount: results.length, + errorCount: errors.length + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`批量删除书签失败: ${error.message}`) + } + } + + // 获取用户书签统计 + async getUserBookmarkStats(userId) { + try { + if (!userId) { + throw new CommonError("用户ID不能为空") + } + + const bookmarks = await BookmarkModel.findAllByUser(userId) + + // 按标签分组统计 + const tagStats = {} + bookmarks.forEach(bookmark => { + if (bookmark.tags) { + const tags = bookmark.tags.split(',').map(tag => tag.trim()) + tags.forEach(tag => { + tagStats[tag] = (tagStats[tag] || 0) + 1 + }) + } + }) + + // 按创建时间分组统计 + const dateStats = {} + bookmarks.forEach(bookmark => { + const date = new Date(bookmark.created_at).toISOString().split('T')[0] + dateStats[date] = (dateStats[date] || 0) + 1 + }) + + return { + total: bookmarks.length, + byTag: tagStats, + byDate: dateStats, + lastUpdated: bookmarks.length > 0 ? bookmarks[0].updated_at : null + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取书签统计失败: ${error.message}`) + } + } + + // 搜索用户书签 + async searchUserBookmarks(userId, keyword) { + try { + if (!userId) { + throw new CommonError("用户ID不能为空") + } + + if (!keyword || keyword.trim() === '') { + return await this.getUserBookmarks(userId) + } + + const bookmarks = await BookmarkModel.findAllByUser(userId) + const searchTerm = keyword.toLowerCase().trim() + + return bookmarks.filter(bookmark => { + return ( + bookmark.title?.toLowerCase().includes(searchTerm) || + bookmark.description?.toLowerCase().includes(searchTerm) || + bookmark.url?.toLowerCase().includes(searchTerm) || + bookmark.tags?.toLowerCase().includes(searchTerm) + ) + }) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`搜索书签失败: ${error.message}`) + } + } + + // 验证URL格式 + isValidUrl(url) { + try { + new URL(url) + return true + } catch { + return false + } + } + + // 获取书签分页 + async getBookmarksWithPagination(userId, page = 1, pageSize = 20) { + try { + if (!userId) { + throw new CommonError("用户ID不能为空") + } + + const allBookmarks = await BookmarkModel.findAllByUser(userId) + const total = allBookmarks.length + const offset = (page - 1) * pageSize + const bookmarks = allBookmarks.slice(offset, offset + pageSize) + + return { + bookmarks, + pagination: { + current: page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize) + } + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`分页获取书签失败: ${error.message}`) + } + } +} + +export default BookmarkService +export { BookmarkService } diff --git a/src/modules/common/controllers/ApiController.js b/src/modules/common/controllers/ApiController.js new file mode 100644 index 0000000..cd64808 --- /dev/null +++ b/src/modules/common/controllers/ApiController.js @@ -0,0 +1,58 @@ +import { R } from "../../../shared/utils/helper.js" +import Router from "../../../shared/utils/router.js" + +class AuthController { + constructor() {} + + /** + * 通用请求函数:依次请求网址数组,返回第一个成功的响应及其类型 + * @param {string[]} urls + * @returns {Promise<{type: string, data: any}>} + */ + async fetchFirstSuccess(urls) { + for (const url of urls) { + try { + const res = await fetch(url, { method: "get", mode: "cors", redirect: "follow" }) + if (!res.ok) continue + const contentType = res.headers.get("content-type") || "" + let data, type + if (contentType.includes("application/json")) { + data = await res.json() + type = "json" + } else if (contentType.includes("text/")) { + data = await res.text() + type = "text" + } else { + data = await res.blob() + type = "blob" + } + return { type, data } + } catch (e) { + // ignore and try next url + } + } + throw new Error("All requests failed") + } + + async random(ctx) { + const { type, data } = await this.fetchFirstSuccess(["https://api.miaomc.cn/image/get"]) + if (type === "blob") { + ctx.set("Content-Type", "image/jpeg") + ctx.body = data + } else { + R.ResponseJSON(R.ERROR, "Failed to fetch image") + } + } + + /** + * 路由注册 + */ + static createRoutes() { + const controller = new AuthController() + const router = new Router({ prefix: "/api/pics" }) + router.get("/random", controller.random.bind(controller), { auth: false }) + return router + } +} + +export default AuthController diff --git a/src/modules/common/controllers/JobController.js b/src/modules/common/controllers/JobController.js new file mode 100644 index 0000000..722a362 --- /dev/null +++ b/src/modules/common/controllers/JobController.js @@ -0,0 +1,46 @@ +// Job Controller 示例:如何调用 service 层动态控制和查询定时任务 +import JobService from "../services/JobService.js" +import { R } from "../../../shared/utils/helper.js" +import Router from "../../../shared/utils/router.js" + +class JobController { + constructor() { + this.jobService = new JobService() + } + + async list(ctx) { + const data = this.jobService.listJobs() + R.ResponseJSON(R.SUCCESS,data) + } + + async start(ctx) { + const { id } = ctx.params + this.jobService.startJob(id) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务已启动`) + } + + async stop(ctx) { + const { id } = ctx.params + this.jobService.stopJob(id) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务已停止`) + } + + async updateCron(ctx) { + const { id } = ctx.params + const { cronTime } = ctx.request.body + this.jobService.updateJobCron(id, cronTime) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务频率已修改`) + } + + static createRoutes() { + const controller = new JobController() + const router = new Router({ prefix: "/api/jobs" }) + router.get("/", controller.list.bind(controller)) + router.post("/start/:id", controller.start.bind(controller)) + router.post("/stop/:id", controller.stop.bind(controller)) + router.post("/update/:id", controller.updateCron.bind(controller)) + return router + } +} + +export default JobController diff --git a/src/modules/common/controllers/PageController.js b/src/modules/common/controllers/PageController.js new file mode 100644 index 0000000..23332db --- /dev/null +++ b/src/modules/common/controllers/PageController.js @@ -0,0 +1,25 @@ +import { R } from "../../../shared/utils/helper.js" +import Router from "../../../shared/utils/router.js" + +class Pageontroller { + constructor() {} + + async indexGet () { + console.log(234); + + return R.ResponseSuccess(Math.random()) + } + + /** + * 路由注册 + */ + static createRoutes() { + const controller = new Pageontroller() + const router = new Router() + router.get("", controller.indexGet.bind(controller), { auth: "try" }) + router.get("/", controller.indexGet.bind(controller), { auth: "try" }) + return router + } +} + +export default Pageontroller diff --git a/src/modules/common/controllers/StatusController.js b/src/modules/common/controllers/StatusController.js new file mode 100644 index 0000000..b31a490 --- /dev/null +++ b/src/modules/common/controllers/StatusController.js @@ -0,0 +1,20 @@ +import Router from "../../../shared/utils/router.js" + +class StatusController { + async status(ctx) { + ctx.body = "OK" + } + + static createRoutes() { + const controller = new StatusController() + const v1 = new Router({ prefix: "/api/v1" }) + v1.use((ctx, next) => { + ctx.set("X-API-Version", "v1") + return next() + }) + v1.get("/status", controller.status.bind(controller)) + return v1 + } +} + +export default StatusController diff --git a/src/modules/common/controllers/UploadController.js b/src/modules/common/controllers/UploadController.js new file mode 100644 index 0000000..5e33b4e --- /dev/null +++ b/src/modules/common/controllers/UploadController.js @@ -0,0 +1,200 @@ +import Router from "../../../shared/utils/router.js" +import formidable from "formidable" +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import { logger } from "../../../app/bootstrap/logger.js" +import { R } from "../../../shared/utils/helper.js" + +/** + * 文件上传控制器 + * 负责处理通用文件上传功能 + */ +class UploadController { + constructor() { + // 初始化上传配置 + this.initConfig() + } + + /** + * 初始化上传配置 + */ + initConfig() { + // 默认支持的文件类型配置 + this.defaultTypeList = [ + { mime: "image/jpeg", ext: ".jpg" }, + { mime: "image/png", ext: ".png" }, + { mime: "image/webp", ext: ".webp" }, + { mime: "image/gif", ext: ".gif" }, + { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx + { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls + { mime: "application/msword", ext: ".doc" }, // .doc + { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx + ] + + this.fallbackExt = ".bin" + this.maxFileSize = 10 * 1024 * 1024 // 10MB + } + + /** + * 获取允许的文件类型 + * @param {Object} ctx - Koa上下文 + * @returns {Array} 允许的文件类型列表 + */ + getAllowedTypes(ctx) { + let typeList = this.defaultTypeList + + // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型) + if (ctx.query.allowedTypes) { + const allowed = ctx.query.allowedTypes + .split(",") + .map(t => t.trim()) + .filter(Boolean) + typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime)) + } + + return typeList + } + + /** + * 获取上传目录路径 + * @returns {string} 上传目录路径 + */ + getUploadDir() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const publicDir = path.resolve(__dirname, "../../../public") + return path.resolve(publicDir, "uploads/files") + } + + /** + * 确保上传目录存在 + * @param {string} dir - 目录路径 + */ + async ensureUploadDir(dir) { + await fs.mkdir(dir, { recursive: true }) + } + + /** + * 生成安全的文件名 + * @param {Object} ctx - Koa上下文 + * @param {string} ext - 文件扩展名 + * @returns {string} 生成的文件名 + */ + generateFileName(ctx, ext) { + return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` + } + + /** + * 获取文件扩展名 + * @param {Object} file - 文件对象 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件扩展名 + */ + getFileExtension(file, typeList) { + // 优先用mimetype判断扩展名 + let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext + if (!ext) { + // 回退到原始文件名的扩展名 + ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt + } + return ext + } + + /** + * 处理单个文件上传 + * @param {Object} file - 文件对象 + * @param {Object} ctx - Koa上下文 + * @param {string} uploadsDir - 上传目录 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件URL + */ + async processFile(file, ctx, uploadsDir, typeList) { + if (!file) return null + + const oldPath = file.filepath || file.path + const ext = this.getFileExtension(file, typeList) + const filename = this.generateFileName(ctx, ext) + const destPath = path.join(uploadsDir, filename) + + // 移动文件到目标位置 + if (oldPath && oldPath !== destPath) { + await fs.rename(oldPath, destPath) + } + + // 返回相对于public的URL路径 + return `/uploads/files/${filename}` + } + + // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组) + async upload(ctx) { + try { + const uploadsDir = this.getUploadDir() + await this.ensureUploadDir(uploadsDir) + + const typeList = this.getAllowedTypes(ctx) + const allowedTypes = typeList.map(item => item.mime) + + const form = formidable({ + multiples: true, // 支持多文件 + maxFileSize: this.maxFileSize, + filter: ({ mimetype }) => { + return !!mimetype && allowedTypes.includes(mimetype) + }, + uploadDir: uploadsDir, + keepExtensions: true, + }) + + const { files } = await new Promise((resolve, reject) => { + form.parse(ctx.req, (err, fields, files) => { + if (err) return reject(err) + resolve({ fields, files }) + }) + }) + + let fileList = files.file + if (!fileList) { + return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file") + } + + // 统一为数组 + if (!Array.isArray(fileList)) { + fileList = [fileList] + } + + // 处理所有文件 + const urls = [] + for (const file of fileList) { + const url = await this.processFile(file, ctx, uploadsDir, typeList) + if (url) { + urls.push(url) + } + } + + ctx.body = { + success: true, + message: "上传成功", + urls, + } + } catch (error) { + logger.error(`上传失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "上传失败" } + } + } + + /** + * 创建文件上传相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new UploadController() + const router = new Router({ auth: "try" }) + + // 通用文件上传 + router.post("/upload", controller.upload.bind(controller), { auth: true }) + + return router + } +} + +export default UploadController \ No newline at end of file diff --git a/src/modules/common/services/JobService.js b/src/modules/common/services/JobService.js new file mode 100644 index 0000000..b6209b9 --- /dev/null +++ b/src/modules/common/services/JobService.js @@ -0,0 +1,18 @@ +import jobs from "../../../infrastructure/jobs/index.js" + +class JobService { + startJob(id) { + return jobs.start(id) + } + stopJob(id) { + return jobs.stop(id) + } + updateJobCron(id, cronTime) { + return jobs.updateCronTime(id, cronTime) + } + listJobs() { + return jobs.list() + } +} + +export default JobService diff --git a/src/modules/site-config/models/SiteConfigModel.js b/src/modules/site-config/models/SiteConfigModel.js new file mode 100644 index 0000000..1981720 --- /dev/null +++ b/src/modules/site-config/models/SiteConfigModel.js @@ -0,0 +1,42 @@ +import db from "../../../infrastructure/database/index.js" + +class SiteConfigModel { + // 获取指定key的配置 + static async get(key) { + const row = await db("site_config").where({ key }).first() + return row ? row.value : null + } + + // 设置指定key的配置(有则更新,无则插入) + static async set(key, value) { + const exists = await db("site_config").where({ key }).first() + if (exists) { + await db("site_config").where({ key }).update({ value, updated_at: db.fn.now() }) + } else { + await db("site_config").insert({ key, value }) + } + } + + // 批量获取多个key的配置 + static async getMany(keys) { + const rows = await db("site_config").whereIn("key", keys) + const result = {} + rows.forEach(row => { + result[row.key] = row.value + }) + return result + } + + // 获取所有配置 + static async getAll() { + const rows = await db("site_config").select("key", "value") + const result = {} + rows.forEach(row => { + result[row.key] = row.value + }) + return result + } +} + +export default SiteConfigModel +export { SiteConfigModel } \ No newline at end of file diff --git a/src/modules/site-config/services/SiteConfigService.js b/src/modules/site-config/services/SiteConfigService.js new file mode 100644 index 0000000..9355fd5 --- /dev/null +++ b/src/modules/site-config/services/SiteConfigService.js @@ -0,0 +1,299 @@ +import SiteConfigModel from "../models/SiteConfigModel.js" +import CommonError from "../../../shared/utils/error/CommonError.js" + +class SiteConfigService { + // 获取指定key的配置 + async get(key) { + try { + if (!key || key.trim() === '') { + throw new CommonError("配置键不能为空") + } + return await SiteConfigModel.get(key.trim()) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`获取配置失败: ${error.message}`) + } + } + + // 设置指定key的配置 + async set(key, value) { + try { + if (!key || key.trim() === '') { + throw new CommonError("配置键不能为空") + } + if (value === undefined || value === null) { + throw new CommonError("配置值不能为空") + } + return await SiteConfigModel.set(key.trim(), value) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`设置配置失败: ${error.message}`) + } + } + + // 批量获取多个key的配置 + async getMany(keys) { + try { + if (!Array.isArray(keys) || keys.length === 0) { + throw new CommonError("配置键列表不能为空") + } + + // 过滤空值并去重 + const validKeys = [...new Set(keys.filter(key => key && key.trim() !== ''))] + if (validKeys.length === 0) { + throw new CommonError("没有有效的配置键") + } + + return await SiteConfigModel.getMany(validKeys) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`批量获取配置失败: ${error.message}`) + } + } + + // 获取所有配置 + async getAll() { + try { + return await SiteConfigModel.getAll() + } catch (error) { + throw new CommonError(`获取所有配置失败: ${error.message}`) + } + } + + // 删除指定key的配置 + async delete(key) { + try { + if (!key || key.trim() === '') { + throw new CommonError("配置键不能为空") + } + + // 先检查配置是否存在 + const exists = await SiteConfigModel.get(key.trim()) + if (!exists) { + throw new CommonError("配置不存在") + } + + // 这里需要在模型中添加删除方法,暂时返回成功 + // TODO: 在SiteConfigModel中添加delete方法 + return { message: "配置删除成功" } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`删除配置失败: ${error.message}`) + } + } + + // 批量设置配置 + async setMany(configs) { + try { + if (!configs || typeof configs !== 'object') { + throw new CommonError("配置数据格式不正确") + } + + const keys = Object.keys(configs) + if (keys.length === 0) { + throw new CommonError("配置数据不能为空") + } + + const results = [] + const errors = [] + + for (const [key, value] of Object.entries(configs)) { + try { + await this.set(key, value) + results.push(key) + } catch (error) { + errors.push({ + key, + value, + error: error.message + }) + } + } + + return { + success: results, + errors, + total: keys.length, + successCount: results.length, + errorCount: errors.length + } + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`批量设置配置失败: ${error.message}`) + } + } + + // 获取配置统计信息 + async getConfigStats() { + try { + const allConfigs = await this.getAll() + const keys = Object.keys(allConfigs) + + const stats = { + total: keys.length, + byType: {}, + byLength: { + short: 0, // 0-50字符 + medium: 0, // 51-200字符 + long: 0 // 200+字符 + } + } + + keys.forEach(key => { + const value = allConfigs[key] + const valueType = typeof value + const valueLength = String(value).length + + // 按类型统计 + stats.byType[valueType] = (stats.byType[valueType] || 0) + 1 + + // 按长度统计 + if (valueLength <= 50) { + stats.byLength.short++ + } else if (valueLength <= 200) { + stats.byLength.medium++ + } else { + stats.byLength.long++ + } + }) + + return stats + } catch (error) { + throw new CommonError(`获取配置统计失败: ${error.message}`) + } + } + + // 搜索配置 + async searchConfigs(keyword) { + try { + if (!keyword || keyword.trim() === '') { + return await this.getAll() + } + + const allConfigs = await this.getAll() + const searchTerm = keyword.toLowerCase().trim() + const results = {} + + Object.entries(allConfigs).forEach(([key, value]) => { + if ( + key.toLowerCase().includes(searchTerm) || + String(value).toLowerCase().includes(searchTerm) + ) { + results[key] = value + } + }) + + return results + } catch (error) { + throw new CommonError(`搜索配置失败: ${error.message}`) + } + } + + // 验证配置值 + validateConfigValue(key, value) { + try { + // 根据不同的配置键进行不同的验证 + switch (key) { + case 'site_name': + if (typeof value !== 'string' || value.trim().length === 0) { + throw new CommonError("站点名称必须是有效的字符串") + } + break + case 'site_description': + if (typeof value !== 'string') { + throw new CommonError("站点描述必须是字符串") + } + break + case 'site_url': + try { + new URL(value) + } catch { + throw new CommonError("站点URL格式不正确") + } + break + case 'posts_per_page': + const num = parseInt(value) + if (isNaN(num) || num < 1 || num > 100) { + throw new CommonError("每页文章数必须是1-100之间的数字") + } + break + case 'enable_comments': + if (typeof value !== 'boolean' && !['true', 'false', '1', '0'].includes(String(value))) { + throw new CommonError("评论开关必须是布尔值") + } + break + default: + // 对于其他配置,只做基本类型检查 + if (value === undefined || value === null) { + throw new CommonError("配置值不能为空") + } + } + + return true + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`配置值验证失败: ${error.message}`) + } + } + + // 设置配置(带验证) + async setWithValidation(key, value) { + try { + // 先验证配置值 + this.validateConfigValue(key, value) + + // 验证通过后设置配置 + return await this.set(key, value) + } catch (error) { + if (error instanceof CommonError) throw error + throw new CommonError(`设置配置失败: ${error.message}`) + } + } + + // 获取默认配置 + getDefaultConfigs() { + return { + site_name: "我的网站", + site_description: "一个基于Koa3的现代化网站", + site_url: "http://localhost:3000", + posts_per_page: 10, + enable_comments: true, + theme: "default", + language: "zh-CN", + timezone: "Asia/Shanghai" + } + } + + // 初始化默认配置 + async initializeDefaultConfigs() { + try { + const defaultConfigs = this.getDefaultConfigs() + const existingConfigs = await this.getAll() + + const configsToSet = {} + Object.entries(defaultConfigs).forEach(([key, value]) => { + if (!(key in existingConfigs)) { + configsToSet[key] = value + } + }) + + if (Object.keys(configsToSet).length > 0) { + await this.setMany(configsToSet) + return { + message: "默认配置初始化成功", + initialized: Object.keys(configsToSet) + } + } + + return { + message: "所有默认配置已存在", + initialized: [] + } + } catch (error) { + throw new CommonError(`初始化默认配置失败: ${error.message}`) + } + } +} + +export default SiteConfigService +export { SiteConfigService } \ No newline at end of file diff --git a/src/modules/user/controllers/ProfileController.js b/src/modules/user/controllers/ProfileController.js new file mode 100644 index 0000000..266a683 --- /dev/null +++ b/src/modules/user/controllers/ProfileController.js @@ -0,0 +1,228 @@ +import Router from "../../../shared/utils/router.js" +import UserService from "../../auth/services/userService.js" +import formidable from "formidable" +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import CommonError from "../../../shared/utils/error/CommonError.js" +import { logger } from "../../../app/bootstrap/logger.js" +import imageThumbnail from "image-thumbnail" + +/** + * 用户资料控制器 + * 负责处理用户资料管理、密码修改、头像上传等功能 + */ +class ProfileController { + constructor() { + this.userService = new UserService() + } + + // 获取用户资料 + async profileGet(ctx) { + if (!ctx.session.user) { + return ctx.redirect("/login") + } + + try { + const user = await this.userService.getUserById(ctx.session.user.id) + return await ctx.render( + "page/profile/index", + { + user, + site_title: "用户资料", + }, + { includeSite: true, includeUser: true } + ) + } catch (error) { + logger.error(`获取用户资料失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: "获取用户资料失败" } + } + } + + // 更新用户资料 + async profileUpdate(ctx) { + if (!ctx.session.user) { + ctx.status = 401 + ctx.body = { success: false, message: "未登录" } + return + } + + try { + const { username, email, name, bio, avatar } = ctx.request.body + + // 验证必填字段 + if (!username) { + ctx.status = 400 + ctx.body = { success: false, message: "用户名不能为空" } + return + } + + const updateData = { username, email, name, bio, avatar } + + // 移除空值 + Object.keys(updateData).forEach(key => { + if (updateData[key] === undefined || updateData[key] === null || updateData[key] === "") { + delete updateData[key] + } + }) + + const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData) + + // 更新session中的用户信息 + ctx.session.user = { ...ctx.session.user, ...updatedUser } + + ctx.body = { + success: true, + message: "资料更新成功", + user: updatedUser, + } + } catch (error) { + logger.error(`更新用户资料失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "更新用户资料失败" } + } + } + + // 修改密码 + async changePassword(ctx) { + if (!ctx.session.user) { + ctx.status = 401 + ctx.body = { success: false, message: "未登录" } + return + } + + try { + const { oldPassword, newPassword, confirmPassword } = ctx.request.body + + if (!oldPassword || !newPassword || !confirmPassword) { + ctx.status = 400 + ctx.body = { success: false, message: "请填写所有密码字段" } + return + } + + if (newPassword !== confirmPassword) { + ctx.status = 400 + ctx.body = { success: false, message: "新密码与确认密码不匹配" } + return + } + + if (newPassword.length < 6) { + ctx.status = 400 + ctx.body = { success: false, message: "新密码长度不能少于6位" } + return + } + + await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword) + + ctx.body = { + success: true, + message: "密码修改成功", + } + } catch (error) { + logger.error(`修改密码失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "修改密码失败" } + } + } + + // 上传头像(multipart/form-data) + async uploadAvatar(ctx) { + try { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const publicDir = path.resolve(__dirname, "../../../public") + const avatarsDir = path.resolve(publicDir, "uploads/avatars") + + // 确保目录存在 + await fs.mkdir(avatarsDir, { recursive: true }) + + const form = formidable({ + multiples: false, + maxFileSize: 5 * 1024 * 1024, // 5MB + filter: ({ mimetype }) => { + return !!mimetype && /^(image\/jpeg|image\/png|image\/webp|image\/gif)$/.test(mimetype) + }, + uploadDir: avatarsDir, + keepExtensions: true, + }) + + const { files } = await new Promise((resolve, reject) => { + form.parse(ctx.req, (err, fields, files) => { + if (err) return reject(err) + resolve({ fields, files }) + }) + }) + + const file = files.avatar || files.file || files.image + const picked = Array.isArray(file) ? file[0] : file + if (!picked) { + ctx.status = 400 + ctx.body = { success: false, message: "未选择文件或字段名应为 avatar" } + return + } + + // formidable v2 的文件对象 + const oldPath = picked.filepath || picked.path + const result = { url: "", thumb: "" } + const ext = path.extname(picked.originalFilename || picked.newFilename || "") || path.extname(oldPath || "") || ".jpg" + const safeExt = [".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(ext.toLowerCase()) ? ext : ".jpg" + const filename = `${ctx.session.user.id}-${Date.now()}/raw${safeExt}` + const destPath = path.join(avatarsDir, filename) + + // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名 + if (oldPath && oldPath !== destPath) { + await fs.mkdir(path.parse(destPath).dir, { recursive: true }) + await fs.rename(oldPath, destPath) + try { + const thumbnail = await imageThumbnail(destPath) + fs.writeFile(destPath.replace(/raw\./, "thumb."), thumbnail) + } catch (err) { + console.error(err) + } + } + + const url = `/uploads/avatars/${filename}` + result.url = url + result.thumb = url.replace(/raw\./, "thumb.") + const updatedUser = await this.userService.updateUser(ctx.session.user.id, { avatar: url }) + ctx.session.user = { ...ctx.session.user, ...updatedUser } + + ctx.body = { + success: true, + message: "头像上传成功", + url, + thumb: result.thumb, + user: updatedUser, + } + } catch (error) { + logger.error(`上传头像失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "上传头像失败" } + } + } + + /** + * 创建用户资料相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new ProfileController() + const router = new Router({ auth: "try" }) + + // 用户资料页面 + router.get("/profile", controller.profileGet.bind(controller), { auth: true }) + + // 用户资料更新 + router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true }) + + // 密码修改 + router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true }) + + // 头像上传 + router.post("/profile/upload-avatar", controller.uploadAvatar.bind(controller), { auth: true }) + + return router + } +} + +export default ProfileController \ No newline at end of file diff --git a/src/presentation/middlewares/Auth/auth.js b/src/presentation/middlewares/Auth/auth.js new file mode 100644 index 0000000..9b77215 --- /dev/null +++ b/src/presentation/middlewares/Auth/auth.js @@ -0,0 +1,73 @@ +import { logger } from "../../../app/bootstrap/logger.js" +import jwt from "./jwt" +import { minimatch } from "minimatch" + +export const JWT_SECRET = process.env.JWT_SECRET + +function matchList(list, path) { + for (const item of list) { + if (typeof item === "string" && minimatch(path, item)) { + return { matched: true, auth: false } + } + if (typeof item === "object" && minimatch(path, item.pattern)) { + return { matched: true, auth: item.auth } + } + } + return { matched: false } +} + +function verifyToken(ctx) { + let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "") + if (!token) { + return { ok: false, status: -1 } + } + try { + ctx.state.user = jwt.verify(token, JWT_SECRET) + return { ok: true } + } catch { + ctx.state.user = undefined + return { ok: false } + } +} + +export default function authMiddleware(options = { + whiteList: [], + blackList: [] +}) { + return async (ctx, next) => { + if(ctx.session.user) { + ctx.state.user = ctx.session.user + } + // 黑名单优先生效 + if (matchList(options.blackList, ctx.path).matched) { + ctx.status = 403 + ctx.body = { success: false, error: "禁止访问" } + return + } + // 白名单处理 + const white = matchList(options.whiteList, ctx.path) + if (white.matched) { + if (white.auth === false) { + return await next() + } + if (white.auth === "try") { + verifyToken(ctx) + return await next() + } + // true 或其他情况,必须有token + if (!verifyToken(ctx).ok) { + ctx.status = 401 + ctx.body = { success: false, error: "未登录或token缺失或无效" } + return + } + return await next() + } + // 非白名单,必须有token + if (!verifyToken(ctx).ok) { + ctx.status = 401 + ctx.body = { success: false, error: "未登录或token缺失或无效" } + return + } + await next() + } +} diff --git a/src/presentation/middlewares/Auth/index.js b/src/presentation/middlewares/Auth/index.js new file mode 100644 index 0000000..bc43ac3 --- /dev/null +++ b/src/presentation/middlewares/Auth/index.js @@ -0,0 +1,3 @@ +// 统一导出所有中间件 +import Auth from "./auth.js" +export { Auth } diff --git a/src/presentation/middlewares/Auth/jwt.js b/src/presentation/middlewares/Auth/jwt.js new file mode 100644 index 0000000..0af32e5 --- /dev/null +++ b/src/presentation/middlewares/Auth/jwt.js @@ -0,0 +1,3 @@ +// 兼容性导出,便于后续扩展 +import jwt from "jsonwebtoken" +export default jwt diff --git a/src/presentation/middlewares/ErrorHandler/index.js b/src/presentation/middlewares/ErrorHandler/index.js new file mode 100644 index 0000000..90ac80c --- /dev/null +++ b/src/presentation/middlewares/ErrorHandler/index.js @@ -0,0 +1,43 @@ +import { logger } from "../../../app/bootstrap/logger.js" +// src/plugins/errorHandler.js +// 错误处理中间件插件 + +async function formatError(ctx, status, message, stack) { + const accept = ctx.accepts("json", "html", "text") + const isDev = process.env.NODE_ENV === "development" + if (accept === "json") { + ctx.type = "application/json" + ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } + } else if (accept === "html") { + ctx.type = "html" + await ctx.render("error/index", { status, message, stack, isDev }) + } else { + ctx.type = "text" + ctx.body = isDev && stack ? `${status} - ${message}\n${stack}` : `${status} - ${message}` + } + ctx.status = status +} + +export default function errorHandler() { + return async (ctx, next) => { + // 拦截 Chrome DevTools 探测请求,直接返回 204 + if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { + ctx.status = 204 + ctx.body = "" + return + } + try { + await next() + if (ctx.status === 404) { + await formatError(ctx, 404, "Resource not found") + } + } catch (err) { + logger.error(err) + const isDev = process.env.NODE_ENV === "development" + if (isDev && err.stack) { + console.error(err.stack) + } + await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) + } + } +} diff --git a/src/presentation/middlewares/ResponseTime/index.js b/src/presentation/middlewares/ResponseTime/index.js new file mode 100644 index 0000000..86e5ab5 --- /dev/null +++ b/src/presentation/middlewares/ResponseTime/index.js @@ -0,0 +1,63 @@ +import { logger } from "../../../app/bootstrap/logger.js" + +// 静态资源扩展名列表 +const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] + +function isStaticResource(path) { + return staticExts.some(ext => path.endsWith(ext)) +} + +/** + * 响应时间记录中间件 + * @param {Object} ctx - Koa上下文对象 + * @param {Function} next - Koa中间件链函数 + */ +export default async (ctx, next) => { + if (isStaticResource(ctx.path)) { + await next() + return + } + if (!ctx.path.includes("/api")) { + const start = Date.now() + await next() + const ms = Date.now() - start + ctx.set("X-Response-Time", `${ms}ms`) + if (ms > 500) { + logger.info(`${ctx.path} | ⏱️ ${ms}ms`) + } + return + } + // API日志记录 + const start = Date.now() + await next() + const ms = Date.now() - start + ctx.set("X-Response-Time", `${ms}ms`) + const Threshold = 0 + if (ms > Threshold) { + logger.info("====================[➡️REQ]====================") + // 用户信息(假设ctx.state.user存在) + const user = ctx.state && ctx.state.user ? ctx.state.user : null + // IP + const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress + // 请求参数 + const params = { + query: ctx.query, + body: ctx.request.body, + } + // 响应状态码 + const status = ctx.status + // 组装日志对象 + const logObj = { + method: ctx.method, + path: ctx.path, + url: ctx.url, + user: user ? { id: user.id, username: user.username } : null, + ip, + params, + status, + ms, + } + logger.info(JSON.stringify(logObj, null, 2)) + logger.info("====================[⬅️END]====================\n") + } +} diff --git a/src/presentation/middlewares/Send/index.js b/src/presentation/middlewares/Send/index.js new file mode 100644 index 0000000..1502d3f --- /dev/null +++ b/src/presentation/middlewares/Send/index.js @@ -0,0 +1,185 @@ +/** + * koa-send@5.0.1 转换为ES Module版本 + * 静态资源服务中间件 + */ +import fs from 'fs'; +import { promisify } from 'util'; +import logger from 'log4js'; +import resolvePath from './resolve-path.js'; +import createError from 'http-errors'; +import assert from 'assert'; +import { normalize, basename, extname, resolve, parse, sep } from 'path'; +import { fileURLToPath } from 'url'; +import path from "path" + +// 转换为ES Module格式 +const log = logger.getLogger('koa-send'); +const stat = promisify(fs.stat); +const access = promisify(fs.access); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 检查文件是否存在 + * @param {string} path - 文件路径 + * @returns {Promise} 文件是否存在 + */ +async function exists(path) { + try { + await access(path); + return true; + } catch (e) { + return false; + } +} + +/** + * 发送文件给客户端 + * @param {Context} ctx - Koa上下文对象 + * @param {String} path - 文件路径 + * @param {Object} [opts] - 配置选项 + * @returns {Promise} - 异步Promise + */ +async function send(ctx, path, opts = {}) { + assert(ctx, 'koa context required'); + assert(path, 'pathname required'); + + // 移除硬编码的public目录,要求必须通过opts.root配置 + const root = opts.root; + if (!root) { + throw new Error('Static root directory must be configured via opts.root'); + } + const trailingSlash = path[path.length - 1] === '/'; + path = path.substr(parse(path).root.length); + const index = opts.index || 'index.html'; + const maxage = opts.maxage || opts.maxAge || 0; + const immutable = opts.immutable || false; + const hidden = opts.hidden || false; + const format = opts.format !== false; + const extensions = Array.isArray(opts.extensions) ? opts.extensions : false; + const brotli = opts.brotli !== false; + const gzip = opts.gzip !== false; + const setHeaders = opts.setHeaders; + + if (setHeaders && typeof setHeaders !== 'function') { + throw new TypeError('option setHeaders must be function'); + } + + // 解码路径 + path = decode(path); + if (path === -1) return ctx.throw(400, 'failed to decode'); + + // 索引文件支持 + if (index && trailingSlash) path += index; + + path = resolvePath(root, path); + + // 隐藏文件支持 + if (!hidden && isHidden(root, path)) return; + + let encodingExt = ''; + // 尝试提供压缩文件 + if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) { + path = path + '.br'; + ctx.set('Content-Encoding', 'br'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.br'; + } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) { + path = path + '.gz'; + ctx.set('Content-Encoding', 'gzip'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.gz'; + } + + // 尝试添加文件扩展名 + if (extensions && !/\./.exec(basename(path))) { + const list = [].concat(extensions); + for (let i = 0; i < list.length; i++) { + let ext = list[i]; + if (typeof ext !== 'string') { + throw new TypeError('option extensions must be array of strings or false'); + } + if (!/^\./.exec(ext)) ext = `.${ext}`; + if (await exists(`${path}${ext}`)) { + path = `${path}${ext}`; + break; + } + } + } + + // 获取文件状态 + let stats; + try { + stats = await stat(path); + + // 处理目录 + if (stats.isDirectory()) { + if (format && index) { + path += `/${index}`; + stats = await stat(path); + } else { + return; + } + } + } catch (err) { + const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; + if (notfound.includes(err.code)) { + throw createError(404, err); + } + err.status = 500; + throw err; + } + + if (setHeaders) setHeaders(ctx.res, path, stats); + + // 设置响应头 + ctx.set('Content-Length', stats.size); + if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()); + if (!ctx.response.get('Cache-Control')) { + const directives = [`max-age=${(maxage / 1000) | 0}`]; + if (immutable) directives.push('immutable'); + ctx.set('Cache-Control', directives.join(',')); + } + if (!ctx.type) ctx.type = type(path, encodingExt); + ctx.body = fs.createReadStream(path); + + return path; +} + +/** + * 检查是否为隐藏文件 + * @param {string} root - 根目录 + * @param {string} path - 文件路径 + * @returns {boolean} 是否为隐藏文件 + */ +function isHidden(root, path) { + path = path.substr(root.length).split(sep); + for (let i = 0; i < path.length; i++) { + if (path[i][0] === '.') return true; + } + return false; +} + +/** + * 获取文件类型 + * @param {string} file - 文件路径 + * @param {string} ext - 编码扩展名 + * @returns {string} 文件MIME类型 + */ +function type(file, ext) { + return ext !== '' ? extname(basename(file, ext)) : extname(file); +} + +/** + * 解码URL路径 + * @param {string} path - 需要解码的路径 + * @returns {string|number} 解码后的路径或错误代码 + */ +function decode(path) { + try { + return decodeURIComponent(path); + } catch (err) { + return -1; + } +} + +export default send; diff --git a/src/presentation/middlewares/Send/resolve-path.js b/src/presentation/middlewares/Send/resolve-path.js new file mode 100644 index 0000000..9c6dce6 --- /dev/null +++ b/src/presentation/middlewares/Send/resolve-path.js @@ -0,0 +1,74 @@ +/*! + * resolve-path + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015-2018 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * ES Module 转换版本 + * 路径解析工具,防止路径遍历攻击 + */ +import createError from 'http-errors'; +import { join, normalize, resolve, sep } from 'path'; +import pathIsAbsolute from 'path-is-absolute'; + +/** + * 模块变量 + * @private + */ +const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/; + +/** + * 解析相对路径到根路径 + * @param {string} rootPath - 根目录路径 + * @param {string} relativePath - 相对路径 + * @returns {string} 解析后的绝对路径 + * @public + */ +function resolvePath(rootPath, relativePath) { + let path = relativePath; + let root = rootPath; + + // root是可选的,类似于root.resolve + if (arguments.length === 1) { + path = rootPath; + root = process.cwd(); + } + + if (root == null) { + throw new TypeError('argument rootPath is required'); + } + + if (typeof root !== 'string') { + throw new TypeError('argument rootPath must be a string'); + } + + if (path == null) { + throw new TypeError('argument relativePath is required'); + } + + if (typeof path !== 'string') { + throw new TypeError('argument relativePath must be a string'); + } + + // 包含NULL字节是恶意的 + if (path.indexOf('\0') !== -1) { + throw createError(400, 'Malicious Path'); + } + + // 路径绝不能是绝对路径 + if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) { + throw createError(400, 'Malicious Path'); + } + + // 路径超出根目录 + if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) { + throw createError(403); + } + + // 拼接相对路径 + return normalize(join(resolve(root), path)); +} + +export default resolvePath; diff --git a/src/presentation/middlewares/Session/index.js b/src/presentation/middlewares/Session/index.js new file mode 100644 index 0000000..47da2a2 --- /dev/null +++ b/src/presentation/middlewares/Session/index.js @@ -0,0 +1,15 @@ +import session from 'koa-session'; + +export default (app) => { + const CONFIG = { + key: 'koa:sess', // cookie key + maxAge: 86400000, // 1天 + httpOnly: true, + signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys + rolling: false, + renew: false, + secure: process.env.NODE_ENV === "production" && process.env.HTTPS_ENABLE === "on", + sameSite: "strict", // https://scotthelme.co.uk/csrf-is-dead/ + }; + return session(CONFIG, app); +}; diff --git a/src/presentation/middlewares/Toast/index.js b/src/presentation/middlewares/Toast/index.js new file mode 100644 index 0000000..ad7a05c --- /dev/null +++ b/src/presentation/middlewares/Toast/index.js @@ -0,0 +1,14 @@ +export default function ToastMiddlewares() { + return function toast(ctx, next) { + if (ctx.toast) return next() + // error success info + ctx.toast = function (type, message) { + ctx.cookies.set("toast", JSON.stringify({ type: type, message: encodeURIComponent(message) }), { + maxAge: 1, + httpOnly: false, + path: "/", + }) + } + return next() + } +} diff --git a/src/presentation/middlewares/Views/index.js b/src/presentation/middlewares/Views/index.js new file mode 100644 index 0000000..82861af --- /dev/null +++ b/src/presentation/middlewares/Views/index.js @@ -0,0 +1,76 @@ +import { resolve } from "path" +import { app } from "../../../app/bootstrap/app.js" +import consolidate from "consolidate" +import send from "../Send" +import getPaths from "get-paths" +// import pretty from "pretty" +import { logger } from "../../../app/bootstrap/logger.js" +import SiteConfigService from "../../../modules/site-config/services/SiteConfigService.js" +import assign from "lodash/assign" +import config from "../../../app/config/index.js" + +export default viewsMiddleware + +function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) { + const siteConfigService = new SiteConfigService() + + return async function views(ctx, next) { + if (ctx.render) return await next() + + // 将 render 注入到 context 和 response 对象中 + ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) { + renderOptions = assign({ includeSite: true, includeUser: false }, renderOptions || {}) + return getPaths(path, relPath, extension).then(async paths => { + const suffix = paths.ext + const site = await siteConfigService.getAll() + const otherData = { + currentPath: ctx.path, + $config: config, + isLogin: !!ctx.state && !!ctx.state.user, + } + if (renderOptions.includeSite) { + otherData.$site = site + } + if (renderOptions.includeUser && ctx.state && ctx.state.user) { + otherData.$user = ctx.state.user + } + const state = assign({}, otherData, locals, options, ctx.state || {}) + // deep copy partials + state.partials = assign({}, options.partials || {}) + // logger.debug("render `%s` with %j", paths.rel, state) + ctx.type = "text/html" + + // 如果是 html 文件,不编译直接 send 静态文件 + if (isHtml(suffix) && !map) { + return send(ctx, paths.rel, { + root: path, + }) + } else { + const engineName = map && map[suffix] ? map[suffix] : suffix + + // 使用 engineSource 配置的渲染引擎 render + const render = engineSource[engineName] + + if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`)) + + return render(resolve(path, paths.rel), state).then(html => { + // since pug has deprecated `pretty` option + // we'll use the `pretty` package in the meanwhile + // if (locals.pretty) { + // debug("using `pretty` package to beautify HTML") + // html = pretty(html) + // } + ctx.body = html + }) + } + }) + } + + // 中间件执行结束 + return await next() + } +} + +function isHtml(ext) { + return ext === "html" +} diff --git a/src/presentation/middlewares/install.js b/src/presentation/middlewares/install.js new file mode 100644 index 0000000..aa9ccdd --- /dev/null +++ b/src/presentation/middlewares/install.js @@ -0,0 +1,70 @@ +import ResponseTime from "./ResponseTime" +import Send from "./Send" +import { resolve } from "path" +import { fileURLToPath } from "url" +import path from "path" +import ErrorHandler from "./ErrorHandler" +import { Auth } from "./Auth" +import bodyParser from "koa-bodyparser" +import Views from "./Views" +import Session from "./Session" +import etag from "@koa/etag" +import conditional from "koa-conditional-get" +import { autoRegisterControllers } from "../../shared/utils/ForRegister.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const publicPath = resolve(__dirname, "../../../public") + +export default async app => { + // 错误处理 + app.use(ErrorHandler()) + // 响应时间 + app.use(ResponseTime) + // session设置 + app.use(Session(app)) + // 权限设置 + app.use( + Auth({ + whiteList: [ + // 所有请求放行 + { pattern: "/", auth: false }, + { pattern: "/**/*", auth: false }, + ], + blackList: [ + // 禁用api请求 + // "/api", + // "/api/", + // "/api/**/*", + ], + }) + ) + // 视图设置 + app.use( + Views(resolve(__dirname, "../views"), { + extension: "pug", + options: { + basedir: resolve(__dirname, "../views"), + }, + }) + ) + // 请求体解析 + app.use(bodyParser()) + // 自动注册控制器 + await autoRegisterControllers(app, path.resolve(__dirname, "../../modules")) + // 注册完成之后静态资源设置 + app.use(async (ctx, next) => { + console.log(11, ctx.body); + if (ctx.body) return await next() + if (ctx.status === 200) return await next() + if (ctx.method.toLowerCase() === "get") { + try { + await Send(ctx, ctx.path, { root: publicPath, maxAge: 0, immutable: false }) + } catch (err) { + if (err.status !== 404) throw err + } + } + await next() + }) + app.use(conditional()) + app.use(etag()) +} diff --git a/src/presentation/views/error/index.pug b/src/presentation/views/error/index.pug new file mode 100644 index 0000000..5d39c06 --- /dev/null +++ b/src/presentation/views/error/index.pug @@ -0,0 +1,8 @@ +html + head + title #{status} Error + body + h1 #{status} Error + p #{message} + if isDev && stack + pre(style="color:red;") #{stack} \ No newline at end of file diff --git a/src/presentation/views/htmx/footer.pug b/src/presentation/views/htmx/footer.pug new file mode 100644 index 0000000..42f27b3 --- /dev/null +++ b/src/presentation/views/htmx/footer.pug @@ -0,0 +1,53 @@ +.footer-panel + .footer-content + p.back-to-top © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 + + ul.footer-links + li + a(href="/") 首页 + li + a(href="/about") 关于我们 + li + a(href="/contact") 联系我们 + style. + .footer-panel { + background: rgba(34,34,34,.25); + backdrop-filter: blur(12px); + color: #eee; + padding: 24px 0 24px 0; + font-size: 15px; + margin-top: 40px; + min-height: 100px; + display: flex; + align-items: center; + justify-content: center; + } + .footer-content { + max-width: 900px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + } + .footer-content p { + margin: 0 0 10px 0; + letter-spacing: 1px; + } + .footer-links { + list-style: none; + padding: 0; + display: flex; + gap: 24px; + } + .footer-links li { + display: inline; + } + .footer-links a { + color: #eee; + text-decoration: none; + transition: color 0.2s; + } + .footer-links a:hover { + color: #4fc3f7; + text-decoration: underline; + } \ No newline at end of file diff --git a/src/presentation/views/htmx/login.pug b/src/presentation/views/htmx/login.pug new file mode 100644 index 0000000..510ec17 --- /dev/null +++ b/src/presentation/views/htmx/login.pug @@ -0,0 +1,13 @@ +if edit + .row.justify-content-center.mt-5 + .col-md-6 + form#loginForm(method="post" action="/api/login" hx-post="/api/login" hx-trigger="submit" hx-target="body" hx-swap="none" hx-on:htmx:afterRequest="if(event.detail.xhr.status===200){window.location='/';}") + .mb-3 + label.form-label(for="username") 用户名 + input.form-control(type="text" id="username" name="username" required) + .mb-3 + label.form-label(for="password") 密码 + input.form-control(type="password" id="password" name="password" required) + button.btn.btn-primary(type="submit") 登录 +else + div sad 404 \ No newline at end of file diff --git a/src/presentation/views/htmx/navbar.pug b/src/presentation/views/htmx/navbar.pug new file mode 100644 index 0000000..8666b55 --- /dev/null +++ b/src/presentation/views/htmx/navbar.pug @@ -0,0 +1,86 @@ +style. + .navbar { + height: 60px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + color: #fff; + &::after { + display: table; + clear: both; + content: ''; + } + } + .navbar .site { + float: left; + height: 100%; + display: flex; + align-items: center; + padding: 0 20px; + cursor: pointer; + font-size: 20px; + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + .menu { + height: 100%; + margin-left: 20px; + .menu-item { + height: 100%; + display: flex; + align-items: center; + padding: 0 10px; + cursor: pointer; + &+.menu-item { + margin-left: 5px; + } + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + } + .menu.left { + float: left; + .menu-item { + float: left; + } + } + .right.menu { + float: right; + .menu-item { + padding: 0 20px; + float: right; + } + } +script. + window.addEventListener('pageshow', function(event) { + // event.persisted 为 true 表示页面从缓存中恢复 + if (event.persisted) { + // 执行需要更新的操作,例如: + console.log('页面从缓存加载,需要更新数据'); + + // 1. 刷新页面(简单直接的方式) + //- window.location.reload(); + + // 2. 重新请求数据(更优雅的方式) + //- fetchData(); // 假设这是你的数据请求函数 + + // 3. 更新页面状态 + //- updatePageState(); // 假设这是你的状态更新函数 + } + }); + +.navbar + .site #{$site.site_title} + .left.menu + a.menu-item(href="/about") 明月照佳人 + a.menu-item(href="/about") 岁月催人老 + if !isLogin + .right.menu + a.menu-item(href="/login") 登录 + a.menu-item(href="/register") 注册 + else + .right.menu + a.menu-item(hx-post="/logout") 退出 + a.menu-item(href="/profile") 欢迎您 , #{$user.username} \ No newline at end of file diff --git a/src/presentation/views/htmx/timeline.pug b/src/presentation/views/htmx/timeline.pug new file mode 100644 index 0000000..6849e9b --- /dev/null +++ b/src/presentation/views/htmx/timeline.pug @@ -0,0 +1,140 @@ +- var _dataList = timeLine || [] +ul.time-line + each item in _dataList + li.time-line-item + .timeline-icon + div !{item.icon} + .time-line-item-content + .time-line-item-title !{item.title} + .time-line-item-desc !{item.desc} + style. + .time-line { + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + } + + .time-line:before { + content: ""; + width: 3px; + height: 100%; + background: rgba(255, 255, 255, 0.37); + backdrop-filter: blur(12px); + left: 50%; + top: 0; + position: absolute; + transform: translateX(-50%); + } + + .time-line::after { + content: ""; + position: absolute; + left: 50%; + top: 100%; + width: 0; + height: 0; + border-top: 12px solid rgba(255, 255, 255, 0.37); + border-right: 7px solid transparent; + border-left: 7px solid transparent; + backdrop-filter: blur(12px); + transform: translateX(-50%); + } + + .time-line a { + color: rgb(219, 255, 121); + text-decoration: underline; + font-weight: 600; + transition: color 0.2s, background 0.2s; + border-radius: 8px; + padding: 1px 4px; + } + .time-line a:hover { + color: #fff; + background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%); + text-decoration: none; + } + + .time-line-item { + color: white; + width: 900px; + margin: 20px auto; + position: relative; + } + + .time-line-item:first-child { + margin-top: 0; + } + + .time-line-item:last-child { + margin-bottom: 50px; + } + + .timeline-icon { + position: absolute; + width: 100px; + height: 50px; + background-color: #ee4d4d7a; + backdrop-filter: blur(12px); + left: 50%; + top: 0; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + font-family: Arial, Helvetica, sans-serif; + } + + .time-line-item-title { + background-color: #ee4d4d7a; + backdrop-filter: blur(12px); + height: 50px; + line-height: 50px; + padding: 0 20px; + } + + .time-line-item:nth-child(odd) .time-line-item-content { + color: white; + width: 50%; + padding-right: 80px; + } + + .time-line-item:nth-child(odd) .time-line-item-content::before { + content: ""; + position: absolute; + left: calc(50% - 80px); + top: 20px; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-left: 7px solid #ee4d4d7a; + backdrop-filter: blur(12px); + } + + .time-line-item:nth-child(even) .time-line-item-content { + float: right; + width: 50%; + padding-left: 80px; + } + + .time-line-item:nth-child(even) .time-line-item-content::before { + content: ""; + position: absolute; + right: calc(50% - 80px); + top: 20px; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid #ee4d4d7a; + backdrop-filter: blur(12px); + } + + .time-line-item-desc { + background-color: #ffffff54; + backdrop-filter: blur(12px); + color: #fff; + padding: 20px; + line-height: 1.4; + } \ No newline at end of file diff --git a/src/presentation/views/layouts/base.pug b/src/presentation/views/layouts/base.pug new file mode 100644 index 0000000..c8f6c3b --- /dev/null +++ b/src/presentation/views/layouts/base.pug @@ -0,0 +1,58 @@ +mixin include() + if block + block + +mixin css(url, extranl = false) + if extranl || url.startsWith('http') || url.startsWith('//') + link(rel="stylesheet" type="text/css" href=url) + else + link(rel="stylesheet", href=($config && $config.base || "") + url) + +mixin js(url, extranl = false) + if extranl || url.startsWith('http') || url.startsWith('//') + script(type="text/javascript" src=url) + else + script(src=($config && $config.base || "") + url) + +mixin link(href, name) + //- attributes == {class: "btn"} + a(href=href)&attributes(attributes)= name + +doctype html +html(lang="zh-CN") + head + block head + title #{site_title || $site && $site.site_title || ''} + meta(name="description" content=site_description || $site && $site.site_description || '') + meta(name="keywords" content=keywords || $site && $site.keywords || '') + if $site && $site.site_favicon + link(rel="shortcut icon", href=$site.site_favicon) + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + +css('reset.css') + +js('lib/htmx.min.js') + +js('https://cdn.tailwindcss.com') + +css('https://unpkg.com/simplebar@latest/dist/simplebar.css', true) + +css('simplebar-shim.css') + +js('https://unpkg.com/simplebar@latest/dist/simplebar.min.js', true) + //- body(style="--bg:url("+($site && $site.site_bg || '#fff')+")") + //- body(style="--bg:url(./static/bg2.webp)") + body + noscript + style. + .simplebar-content-wrapper { + scrollbar-width: auto; + -ms-overflow-style: auto; + } + + .simplebar-content-wrapper::-webkit-scrollbar, + .simplebar-hide-scrollbar::-webkit-scrollbar { + display: initial; + width: initial; + height: initial; + } + div(data-simplebar style="height: 100%") + div(style="height: 100%; display: flex; flex-direction: column") + block content + block scripts + +js('lib/bg-change.js') diff --git a/src/presentation/views/layouts/bg-page.pug b/src/presentation/views/layouts/bg-page.pug new file mode 100644 index 0000000..48c4374 --- /dev/null +++ b/src/presentation/views/layouts/bg-page.pug @@ -0,0 +1,18 @@ +extends /layouts/root.pug +//- 采用纯背景页面的布局,背景图片随机切换,卡片采用高斯滤镜类玻璃化效果 +//- .card + +block $$head + +css('css/layouts/bg-page.css') + block pageHead + +block $$content + .page-layout + .page + block pageContent + footer + include /htmx/footer.pug + +block $$scripts + +js('lib/bg-change.js') + block pageScripts diff --git a/src/presentation/views/layouts/empty.pug b/src/presentation/views/layouts/empty.pug new file mode 100644 index 0000000..2a97747 --- /dev/null +++ b/src/presentation/views/layouts/empty.pug @@ -0,0 +1,122 @@ +extends /layouts/root.pug +//- 采用纯背景页面的布局,背景图片随机切换,卡片采用高斯滤镜类玻璃化效果 + +block $$head + +css('css/layouts/empty.css') + block pageHead + +block $$content + nav.navbar(class="relative") + .placeholder.mb-5(class="h-[45px] w-full opacity-0") + .fixed-container(class="shadow fixed bg-white h-[45px] top-0 left-0 right-0 z-10") + .container.clearfix(class="h-full") + .navbar-brand + a(href="/" class="text-[20px]") + #{$site.site_title} + // 桌面端菜单 + .left.menu.desktop-only + a.menu-item( + href="/articles" + class=(currentPath === '/articles' || currentPath === '/articles/' + ? 'text-blue-600 font-bold border-b-2 border-blue-600' + : 'text-gray-700 hover:text-blue-600 hover:border-b-2 hover:border-blue-400' + ) + ) 所有文章 + if !isLogin + .right.menu.desktop-only + a.menu-item(href="/login") 登录 + a.menu-item(href="/register") 注册 + else + .right.menu.desktop-only + a.menu-item(hx-post="/logout") 退出 + a.menu-item(href="/profile") 欢迎您 , #{$user.name} + a.menu-item(href="/notice") + .fe--notice-active + // 移动端:汉堡按钮 + button.menu-toggle(type="button" aria-label="打开菜单") + span.bar + span.bar + span.bar + // 移动端菜单内容(与桌面端一致) + .mobile-menu.container + .left.menu + a.menu-item(href="/articles") 所有文章 + if !isLogin + .right.menu + a.menu-item(href="/login") 登录 + a.menu-item(href="/register") 注册 + else + .right.menu + a.menu-item(hx-post="/logout") 退出 + a.menu-item() 欢迎您 , #{$user.name} + a.menu-item(href="/notice" class="fe--notice-active") 公告 + .page-layout + .page.container + block pageContent + + footer.footer.shadow.mt-5 + .footer-panel(class="bg-white border-t border-gray-200") + .footer-content.container(class="pt-12 pb-6") + .footer-main(class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8") + .footer-section + h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") #{$site.site_title} + p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 + + .footer-section + h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 快速链接 + ul.footer-links(class="space-y-3") + li + a(href="/" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 首页 + li + a(href="/about" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 关于我们 + li + a(href="/contact" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 联系我们 + li + a(href="/help" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 帮助中心 + + .footer-section + h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 服务支持 + ul.footer-links(class="space-y-3") + li + a(href="/terms" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 服务条款 + li + a(href="/privacy" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 隐私政策 + li + a(href="/faq" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 常见问题 + li + a(href="/feedback" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 意见反馈 + + .footer-section + h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 关注我 + .social-links(class="flex space-x-4 flex-wrap") + a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-blue-100 transition-colors duration-200" title="微信") + span.streamline-ultimate-color--wechat-logo + // a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-red-100 transition-colors duration-200" title="微博") + span.fa7-brands--weibo + a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-blue-100 transition-colors duration-200" title="QQ") + span.cib--tencent-qq + a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors duration-200" title="GitHub") + span.ri--github-fill + a(href="https://blog.xieyaxin.top" target="_blank" class="social-link p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors duration-200" title="GitHub") + span.icomoon-free--blog + + .footer-bottom(class="border-t border-gray-200 pt-6") + .footer-bottom-content(class="flex flex-col md:flex-row justify-between items-center") + .copyright(class="text-gray-500 text-sm mb-4 md:mb-0") + | © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 + .footer-actions(class="flex items-center space-x-6") + a(href="/sitemap" class="text-gray-500 hover:text-blue-600 transition-colors duration-200 text-sm") 网站地图 + a(href="/rss" class="text-gray-500 hover:text-blue-600 transition-colors duration-200 text-sm") RSS订阅 + +block $$scripts + block pageScripts + script. + (function(){ + var navbar = document.querySelector('.navbar'); + var toggle = navbar && navbar.querySelector('.menu-toggle'); + if(toggle){ + toggle.addEventListener('click', function(){ + navbar.classList.toggle('open'); + }); + } + })(); diff --git a/src/presentation/views/layouts/page.pug b/src/presentation/views/layouts/page.pug new file mode 100644 index 0000000..f6353e1 --- /dev/null +++ b/src/presentation/views/layouts/page.pug @@ -0,0 +1,31 @@ +extends /layouts/base.pug + +block head + +css('styles.css') + block pageHead + +block content + .page-layout + .page + - const navs = []; + - navs.push({ href: '/', label: '首页' }); + - navs.push({ href: '/articles', label: '文章' }); + - navs.push({ href: '/article', label: '收藏' }); + - navs.push({ href: '/about', label: '关于' }); + nav.nav + ul.flota-nav + each nav in navs + li + a.item( + href=nav.href, + class=currentPath === nav.href ? 'active' : '' + ) #{nav.label} + .content + block pageContent + footer + +include() + - var edit = false + include /htmx/footer.pug + +block scripts + block pageScripts diff --git a/src/presentation/views/layouts/pure.pug b/src/presentation/views/layouts/pure.pug new file mode 100644 index 0000000..7727749 --- /dev/null +++ b/src/presentation/views/layouts/pure.pug @@ -0,0 +1,16 @@ +extends /layouts/root.pug + +block $$head + +css('styles.css') + block pageHead + +block $$content + .page-layout + .page + .content + block pageContent + footer + include /htmx/footer.pug + +block $$scripts + block pageScripts diff --git a/src/presentation/views/layouts/root.pug b/src/presentation/views/layouts/root.pug new file mode 100644 index 0000000..479f568 --- /dev/null +++ b/src/presentation/views/layouts/root.pug @@ -0,0 +1,69 @@ +include utils.pug + +doctype html +html(lang="zh-CN") + head + block $$head + title #{site_title || $site && $site.site_title || ''} + meta(name="description" content=site_description || $site && $site.site_description || '') + meta(name="keywords" content=keywords || $site && $site.keywords || '') + if $site && $site.site_favicon + link(rel="shortcut icon", href=$site.site_favicon) + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + +css('lib/reset.css') + +css('lib/simplebar.css') + +css('lib/simplebar-shim.css') + +css('css/layouts/root.css') + +js('lib/htmx.min.js') + +js('lib/tailwindcss.3.4.17.js') + +js('lib/simplebar.min.js') + body + noscript + style. + .simplebar-content-wrapper { + scrollbar-width: auto; + -ms-overflow-style: auto; + } + + .simplebar-content-wrapper::-webkit-scrollbar, + .simplebar-hide-scrollbar::-webkit-scrollbar { + display: initial; + width: initial; + height: initial; + } + div(data-simplebar style="height: 100%") + div(style="height: 100%; display: flex; flex-direction: column") + block $$content + block $$scripts + script. + //- 处理滚动条位置 + const el = document.querySelector('.simplebar-content-wrapper') + const scrollTop = sessionStorage.getItem('scrollTop-'+location.pathname) + window.onload = function() { + el.scrollTop = scrollTop + el.addEventListener("scroll", function(e) { + sessionStorage.setItem('scrollTop-'+location.pathname, e.target.scrollTop) + }) + } + //- 处理点击慢慢回到顶部 + const backToTopBtn = document.querySelector('.back-to-top'); + if (backToTopBtn) { + backToTopBtn.addEventListener('click', function(e) { + e.preventDefault(); + const el = document.querySelector('.simplebar-content-wrapper'); + if (!el) return; + const duration = 400; + const start = el.scrollTop; + const startTime = performance.now(); + function animateScroll(currentTime) { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + el.scrollTop = start * (1 - progress); + if (progress < 1) { + requestAnimationFrame(animateScroll); + } + } + requestAnimationFrame(animateScroll); + }); + } \ No newline at end of file diff --git a/src/presentation/views/layouts/utils.pug b/src/presentation/views/layouts/utils.pug new file mode 100644 index 0000000..7cc90a7 --- /dev/null +++ b/src/presentation/views/layouts/utils.pug @@ -0,0 +1,23 @@ +mixin include() + if block + block +//- include的使用方法 +//- +include() +//- - var edit = false +//- include /htmx/footer.pug + +mixin css(url, extranl = false) + if extranl || url.startsWith('http') || url.startsWith('//') + link(rel="stylesheet" type="text/css" href=url) + else + link(rel="stylesheet", href=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url)) + +mixin js(url, extranl = false) + if extranl || url.startsWith('http') || url.startsWith('//') + script(type="text/javascript" src=url) + else + script(src=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url)) + +mixin link(href, name) + //- attributes == {class: "btn"} + a(href=href)&attributes(attributes)= name \ No newline at end of file diff --git a/src/presentation/views/page/about/index.pug b/src/presentation/views/page/about/index.pug new file mode 100644 index 0000000..f2b82d7 --- /dev/null +++ b/src/presentation/views/page/about/index.pug @@ -0,0 +1,20 @@ +extends /layouts/bg-page.pug + +block pageContent + .about-container.card + h1 关于我们 + p 我们致力于打造一个基于 Koa3 的现代 Web 示例项目,帮助开发者快速上手高效、可扩展的 Web 应用开发。 + .about-section + h2 我们的愿景 + p 推动 Node.js 生态下的现代 Web 技术发展,降低开发门槛,提升开发体验。 + .about-section + h2 技术栈 + ul + li Koa3 + li Pug 模板引擎 + li 现代前端技术(如 ES6+、CSS3) + .about-section + h2 联系我们 + p 如有建议或合作意向,欢迎通过 + a(href="mailto:1549469775@qq.com") 联系方式 + | 与我们取得联系。 diff --git a/src/presentation/views/page/articles/article.pug b/src/presentation/views/page/articles/article.pug new file mode 100644 index 0000000..a92df10 --- /dev/null +++ b/src/presentation/views/page/articles/article.pug @@ -0,0 +1,70 @@ +extends /layouts/empty.pug + +block pageContent + .container.mx-auto.px-4.py-8 + article.max-w-4xl.mx-auto + header.mb-8 + h1.text-4xl.font-bold.mb-4= article.title + .flex.flex-wrap.items-center.text-gray-600.mb-4 + span.mr-4 + i.fas.fa-calendar-alt.mr-1 + = new Date(article.published_at).toLocaleDateString() + span.mr-4 + i.fas.fa-eye.mr-1 + = article.view_count + " 阅读" + if article.reading_time + span.mr-4 + i.fas.fa-clock.mr-1 + = article.reading_time + " 分钟阅读" + if article.category + a.text-blue-600.mr-4(href=`/articles/category/${article.category}` class="hover:text-blue-800") + i.fas.fa-folder.mr-1 + = article.category + if article.status === "draft" + span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 + + if article.tags + .flex.flex-wrap.gap-2.mb-4 + each tag in article.tags.split(',') + a.bg-gray-100.text-gray-700.px-3.py-1.rounded-full.text-sm(href=`/articles/tag/${tag.trim()}` class="hover:bg-gray-200") + i.fas.fa-tag.mr-1 + = tag.trim() + + if article.featured_image + .mb-8 + img.w-full.rounded-lg.shadow-lg(src=article.featured_image alt=article.title) + + .prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md") + != article.content + + if article.keywords || article.description + .bg-gray-50.rounded-lg.p-6.mb-8 + if article.keywords + .mb-4 + h3.text-lg.font-semibold.mb-2 关键词 + .flex.flex-wrap.gap-2 + each keyword in article.keywords.split(',') + span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim() + if article.description + h3.text-lg.font-semibold.mb-2 描述 + p.text-gray-600= article.description + + if relatedArticles && relatedArticles.length + section.border-t.pt-8.mt-8 + h2.text-2xl.font-bold.mb-6 相关文章 + .grid.grid-cols-1.gap-6(class="md:grid-cols-2") + each related in relatedArticles + .bg-white.shadow-md.rounded-lg.overflow-hidden + if related.featured_image + img.w-full.h-48.object-cover(src=related.featured_image alt=related.title) + .p-6 + h3.text-xl.font-semibold.mb-2 + a(href=`/articles/${related.slug}` class="hover:text-blue-600")= related.title + if related.excerpt + p.text-gray-600.text-sm.mb-4= related.excerpt + .flex.justify-between.items-center.text-sm.text-gray-500 + span + i.fas.fa-calendar-alt.mr-1 + = new Date(related.published_at).toLocaleDateString() + if related.category + a.text-blue-600(href=`/articles/category/${related.category}` class="hover:text-blue-800")= related.category diff --git a/src/presentation/views/page/articles/category.pug b/src/presentation/views/page/articles/category.pug new file mode 100644 index 0000000..5881ff3 --- /dev/null +++ b/src/presentation/views/page/articles/category.pug @@ -0,0 +1,29 @@ +extends /layouts/empty.pug + +block pageContent + .container.mx-auto.py-8 + h1.text-3xl.font-bold.mb-8 + span.text-gray-600 分类: + = category + + .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") + each article in articles + .bg-white.shadow-md.rounded-lg.overflow-hidden + if article.featured_image + img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) + .p-6 + h2.text-xl.font-semibold.mb-2 + a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title + if article.excerpt + p.text-gray-600.mb-4= article.excerpt + .flex.justify-between.items-center.text-sm.text-gray-500 + span + i.fas.fa-calendar-alt.mr-1 + = new Date(article.published_at).toLocaleDateString() + span + i.fas.fa-eye.mr-1 + = article.view_count + " 阅读" + + if !articles.length + .text-center.py-8 + p.text-gray-500 该分类下暂无文章 diff --git a/src/presentation/views/page/articles/index.pug b/src/presentation/views/page/articles/index.pug new file mode 100644 index 0000000..5c4cfeb --- /dev/null +++ b/src/presentation/views/page/articles/index.pug @@ -0,0 +1,134 @@ +extends /layouts/empty.pug + +block pageContent + .flex.flex-col + .flex-1 + .container.mx-auto + // 页头 + .flex.justify-between.items-center.mb-8 + h1.text-2xl.font-bold 文章列表 + .flex.gap-4 + // 搜索框 + .relative + input#searchInput.w-64.pl-10.pr-4.py-2.border.rounded-lg( + type="text" + placeholder="搜索文章..." + hx-get="/articles/search" + hx-trigger="keyup changed delay:500ms" + hx-target="#articleList" + hx-swap="outerHTML" + name="q" + class="focus:outline-none focus:ring-blue-500 focus:ring-2" + ) + i.fas.fa-search.absolute.left-3.top-3.text-gray-400 + + // 视图切换按钮 + //- .flex.items-center.gap-2.bg-white.p-1.rounded-lg.border + //- button.p-2.rounded( + //- class="hover:bg-gray-100" + //- hx-get="/articles?view=grid" + //- hx-target="#articleList" + //- ) + //- i.fas.fa-th-large + //- button.p-2.rounded( + //- class="hover:bg-gray-100" + //- hx-get="/articles?view=list" + //- hx-target="#articleList" + //- ) + //- i.fas.fa-list + + // 筛选栏 + .bg-white.rounded-lg.shadow-sm.p-4.mb-6 + .flex.flex-wrap.gap-4 + if categories && categories.length + .flex.items-center.gap-2 + span.text-gray-600 分类: + each cat in categories + a.px-3.py-1.rounded-full( + class="hover:bg-blue-50 hover:text-blue-600" + (cat === currentCategory ? " bg-blue-100 text-blue-600" : "") + href=`/articles/category/${cat}` + )= cat + + if tags && tags.length + .flex.items-center.gap-2 + span.text-gray-600 标签: + each tag in tags + a.px-3.py-1.rounded-full( + class="hover:bg-blue-50 hover:text-blue-600" + (tag === currentTag ? " bg-blue-100 text-blue-600" : "") + href=`/articles/tag/${tag}` + )= tag + + // 文章列表 + #articleList.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") + each article in articles + .bg-white.rounded-lg.shadow-sm.overflow-hidden.transition.duration-300.transform(class="hover:-translate-y-1 hover:shadow-md") + if article.featured_image + .relative.h-48 + img.w-full.h-full.object-cover(src=article.featured_image alt=article.title) + if article.category + a.absolute.top-3.right-3.px-3.py-1.bg-blue-600.text-white.text-sm.rounded-full.opacity-90( + href=`/articles/category/${article.category}` + class="hover:opacity-100" + )= article.category + .p-6 + h2.text-xl.font-bold.mb-3 + a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title + if article.excerpt + p.text-gray-600.text-sm.mb-4.line-clamp-2= article.excerpt + + .flex.flex-wrap.gap-2.mb-4 + if article.tags + each tag in article.tags.split(',') + a.text-sm.text-gray-500( + href=`/articles/tag/${tag.trim()}` + class="hover:text-blue-600" + ) + i.fas.fa-tag.mr-1 + = tag.trim() + + .flex.justify-between.items-center.text-sm.text-gray-500 + .flex.items-center.gap-4 + span + i.far.fa-calendar.mr-1 + = new Date(article.published_at).toLocaleDateString() + if article.reading_time + span + i.far.fa-clock.mr-1 + = article.reading_time + "分钟" + span + i.far.fa-eye.mr-1 + = article.view_count + " 阅读" + + if !articles.length + .col-span-full.py-16.text-center + .text-gray-400.mb-4 + i.fas.fa-inbox.text-6xl + p.text-gray-500 暂无文章 + + // 分页 + if totalPages > 1 + .flex.justify-center.mt-8 + nav.flex.items-center.gap-1(aria-label="Pagination") + // 上一页 + if currentPage > 1 + a.px-3.py-1.rounded-md.bg-white.border( + href=`/articles?page=${currentPage - 1}` + class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" + ) 上一页 + + // 页码 + each page in Array.from({length: totalPages}, (_, i) => i + 1) + if page === currentPage + span.px-3.py-1.rounded-md.bg-blue-50.text-blue-600.border.border-blue-200= page + else + a.px-3.py-1.rounded-md.bg-white.border( + href=`/articles?page=${page}` + class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" + )= page + + // 下一页 + if currentPage < totalPages + a.px-3.py-1.rounded-md.bg-white.border( + href=`/articles?page=${currentPage + 1}` + class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" + ) 下一页 diff --git a/src/presentation/views/page/articles/search.pug b/src/presentation/views/page/articles/search.pug new file mode 100644 index 0000000..65af296 --- /dev/null +++ b/src/presentation/views/page/articles/search.pug @@ -0,0 +1,34 @@ +//- extends /layouts/empty.pug + +//- block pageContent +#articleList.container.mx-auto.px-4.py-8 + .mb-8 + h1.text-3xl.font-bold.mb-4 + span.text-gray-600 搜索结果: + = keyword + p.text-gray-500 找到 #{articles.length} 篇相关文章 + + .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") + each article in articles + .bg-white.shadow-md.rounded-lg.overflow-hidden + if article.featured_image + img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) + .p-6 + h2.text-xl.font-semibold.mb-2 + a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title + if article.excerpt + p.text-gray-600.mb-4= article.excerpt + .flex.justify-between.items-center + .text-sm.text-gray-500 + span.mr-4 + i.fas.fa-calendar-alt.mr-1 + = new Date(article.published_at).toLocaleDateString() + span + i.fas.fa-eye.mr-1 + = article.view_count + " 阅读" + if article.category + a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category + + if !articles.length + .text-center.py-8 + p.text-gray-500 未找到相关文章 diff --git a/src/presentation/views/page/articles/tag.pug b/src/presentation/views/page/articles/tag.pug new file mode 100644 index 0000000..c780655 --- /dev/null +++ b/src/presentation/views/page/articles/tag.pug @@ -0,0 +1,32 @@ +extends /layouts/empty.pug + +block pageContent + .container.mx-auto.py-8 + h1.text-3xl.font-bold.mb-8 + span.text-gray-600 标签: + = tag + + .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") + each article in articles + .bg-white.shadow-md.rounded-lg.overflow-hidden + if article.featured_image + img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) + .p-6 + h2.text-xl.font-semibold.mb-2 + a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title + if article.excerpt + p.text-gray-600.mb-4= article.excerpt + .flex.justify-between.items-center + .text-sm.text-gray-500 + span.mr-4 + i.fas.fa-calendar-alt.mr-1 + = new Date(article.published_at).toLocaleDateString() + span + i.fas.fa-eye.mr-1 + = article.view_count + " 阅读" + if article.category + a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category + + if !articles.length + .text-center.py-8 + p.text-gray-500 该标签下暂无文章 diff --git a/src/presentation/views/page/auth/no-auth.pug b/src/presentation/views/page/auth/no-auth.pug new file mode 100644 index 0000000..d578636 --- /dev/null +++ b/src/presentation/views/page/auth/no-auth.pug @@ -0,0 +1,54 @@ +extends /layouts/empty.pug + +block pageContent + .no-auth-container + .no-auth-icon + i.fa.fa-lock + h2 访问受限 + p 您没有权限访问此页面,请先登录或联系管理员。 + a.btn(href='/login') 去登录 + +block pageHead + style. + .no-auth-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; + } + .no-auth-icon { + font-size: 4em; + color: #ff6a6a; + margin-bottom: 20px; + } + .no-auth-container h2 { + margin: 0 0 10px 0; + color: #333; + } + .no-auth-container p { + color: #888; + margin-bottom: 24px; + } + .no-auth-container .btn { + display: inline-block; + padding: 10px 32px; + background: linear-gradient(90deg, #4fd1ff 0%, #ff6a6a 100%); + color: #fff; + border: none; + border-radius: 24px; + font-size: 1.1em; + text-decoration: none; + transition: background 0.2s; + cursor: pointer; + } + .no-auth-container .btn:hover { + background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%); + color: #fff200; + } + +//- block pageScripts +//- script. + //- const curUrl = URL.parse(location.href).searchParams.get("from") + //- fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e)) \ No newline at end of file diff --git a/src/presentation/views/page/extra/contact.pug b/src/presentation/views/page/extra/contact.pug new file mode 100644 index 0000000..f334074 --- /dev/null +++ b/src/presentation/views/page/extra/contact.pug @@ -0,0 +1,83 @@ +extends /layouts/empty.pug + +block pageHead + +block pageContent + .contact.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 联系我们 + p(class="text-gray-600 mb-8 text-center text-lg") 我们非常重视您的反馈和建议,欢迎通过以下方式与我们取得联系 + + // 联系信息 + .contact-info(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center justify-center") + span(class="mr-2") 📞 + | 联系方式 + .grid.grid-cols-1.md:grid-cols-3.gap-6 + .contact-card(class="text-center p-6 bg-blue-50 rounded-lg border border-blue-200 hover:shadow-md transition-shadow") + .icon(class="text-4xl mb-3") 📧 + h3(class="font-semibold text-blue-800 mb-2") 邮箱联系 + p(class="text-gray-700 mb-2") support@example.com + p(class="text-sm text-gray-500") 工作日 24 小时内回复 + .contact-card(class="text-center p-6 bg-green-50 rounded-lg border border-green-200 hover:shadow-md transition-shadow") + .icon(class="text-4xl mb-3") 💬 + h3(class="font-semibold text-green-800 mb-2") 在线客服 + p(class="text-gray-700 mb-2") 工作日 9:00-18:00 + p(class="text-sm text-gray-500") 实时在线解答 + .contact-card(class="text-center p-6 bg-purple-50 rounded-lg border border-purple-200 hover:shadow-md transition-shadow") + .icon(class="text-4xl mb-3") 📱 + h3(class="font-semibold text-purple-800 mb-2") 社交媒体 + p(class="text-gray-700 mb-2") 微信、QQ、GitHub + p(class="text-sm text-gray-500") 关注获取最新动态 + + // 联系表单 + .contact-form(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center justify-center") + span(class="mr-2") ✍️ + | 留言反馈 + .form-container(class="max-w-2xl mx-auto") + form(action="/contact" method="POST" class="space-y-4") + .form-group(class="grid grid-cols-1 md:grid-cols-2 gap-4") + .input-group + label(for="name" class="block text-sm font-medium text-gray-700 mb-1") 姓名 * + input#name(type="text" name="name" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") + .input-group + label(for="email" class="block text-sm font-medium text-gray-700 mb-1") 邮箱 * + input#email(type="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") + .form-group + label(for="subject" class="block text-sm font-medium text-gray-700 mb-1") 主题 * + select#subject(name="subject" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") + option(value="") 请选择反馈类型 + option(value="bug") 问题反馈 + option(value="feature") 功能建议 + option(value="content") 内容相关 + option(value="other") 其他 + .form-group + label(for="message" class="block text-sm font-medium text-gray-700 mb-1") 留言内容 * + textarea#message(name="message" rows="5" required placeholder="请详细描述您的问题或建议..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical") + .form-group(class="text-center") + button(type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors") 提交留言 + + // 办公地址 + .office-info(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center justify-center") + span(class="mr-2") 🏢 + | 办公地址 + .office-card(class="max-w-2xl mx-auto p-6 bg-gray-50 rounded-lg border border-gray-200") + .office-details(class="text-center") + h3(class="font-semibold text-gray-800 mb-2") 公司总部 + p(class="text-gray-700 mb-2") 北京市朝阳区某某大厦 + p(class="text-gray-700 mb-2") 邮编:100000 + p(class="text-sm text-gray-500") 工作时间:周一至周五 9:00-18:00 + + // 相关链接 + .contact-links(class="text-center pt-6 border-t border-gray-200") + p(class="text-gray-600 mb-3") 更多帮助资源: + .links(class="flex flex-wrap justify-center gap-4") + a(href="/help" class="text-blue-600 hover:text-blue-800 hover:underline") 帮助中心 + a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 + a(href="/feedback" class="text-blue-600 hover:text-blue-800 hover:underline") 意见反馈 + a(href="/about" class="text-blue-600 hover:text-blue-800 hover:underline") 关于我们 + + .contact-footer(class="text-center mt-8 pt-6 border-t border-gray-200") + p(class="text-gray-500 text-sm") 我们承诺保护您的隐私,所有联系信息仅用于回复您的反馈 + p(class="text-gray-400 text-xs mt-2") 感谢您的支持与信任 diff --git a/src/presentation/views/page/extra/faq.pug b/src/presentation/views/page/extra/faq.pug new file mode 100644 index 0000000..5b0761b --- /dev/null +++ b/src/presentation/views/page/extra/faq.pug @@ -0,0 +1,55 @@ +extends /layouts/empty.pug + +block pageHead + +block pageContent + .faq.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-2xl font-bold mb-4") 常见问题(FAQ) + p(class="text-gray-600 mb-6") 为帮助您快速了解与使用本站,这里汇总了常见问答。 + + // 基础使用 + h2(class="text-xl font-semibold mt-6 mb-3") 一、基础使用 + dl.divide-y.divide-gray-100 + div.py-4 + dt.font-medium 我如何注册与登录? + dd.text-gray-700.mt-1 访问“注册/登录”页面,按提示完成信息填写即可。如遇验证码问题,请刷新或稍后重试。 + div.py-4 + dt.font-medium 忘记密码怎么办? + dd.text-gray-700.mt-1 目前暂未开放自助找回功能,请通过页脚联系方式与我们取得联系协助处理。 + + // 账号与安全 + h2(class="text-xl font-semibold mt-6 mb-3") 二、账号与安全 + dl.divide-y.divide-gray-100 + div.py-4 + dt.font-medium 如何提升账户安全? + dd.text-gray-700.mt-1 使用强密码、定期更换、不在公共设备保存登录信息,退出时及时登出。 + div.py-4 + dt.font-medium 我的数据会被如何使用? + dd.text-gray-700.mt-1 我们严格遵循最小必要与合规原则处理数据,详见 + a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 + | 。 + + // 功能与服务 + h2(class="text-xl font-semibold mt-6 mb-3") 三、功能与服务 + dl.divide-y.divide-gray-100 + div.py-4 + dt.font-medium 你们提供哪些公开 API? + dd.text-gray-700.mt-1 可在首页“API 列表”中查看示例与说明,或关注文档更新。 + div.py-4 + dt.font-medium 页面打不开/出现错误怎么办? + dd.text-gray-700.mt-1 刷新页面、清理缓存或更换网络环境;如仍有问题,请将报错信息与时间反馈给我们。 + + // 合规与条款 + h2(class="text-xl font-semibold mt-6 mb-3") 四、合规与条款 + dl.divide-y.divide-gray-100 + div.py-4 + dt.font-medium 需要遵守哪些条款? + dd.text-gray-700.mt-1 使用前请阅读并同意 + a(href="/terms" class="text-blue-600 hover:underline") 服务条款 + | 与 + a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 + | 。 + + p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 + + diff --git a/src/presentation/views/page/extra/feedback.pug b/src/presentation/views/page/extra/feedback.pug new file mode 100644 index 0000000..985b18b --- /dev/null +++ b/src/presentation/views/page/extra/feedback.pug @@ -0,0 +1,28 @@ +extends /layouts/empty.pug + +block pageHead + +block pageContent + .feedback.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-2xl font-bold mb-2") 意见反馈 + p(class="text-gray-600 mb-6") 欢迎提出您的建议或问题,我们会尽快处理。 + + form(class="space-y-4" method="post" action="#" onsubmit="alert('感谢反馈!'); return false;") + .grid.grid-cols-1(class="md:grid-cols-2 gap-4") + .form-item + label.block.text-sm.text-gray-600.mb-1(for="name") 您的称呼 + input#name(type="text" name="name" placeholder="例如:张三" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") + .form-item + label.block.text-sm.text-gray-600.mb-1(for="email") 邮箱(可选) + input#email(type="email" name="email" placeholder="用于回复您" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") + .form-item + label.block.text-sm.text-gray-600.mb-1(for="subject") 主题 + input#subject(type="text" name="subject" placeholder="简要概括问题/建议" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") + .form-item + label.block.text-sm.text-gray-600.mb-1(for="content") 详细描述 + textarea#content(name="content" rows="6" placeholder="请尽量描述清楚场景、复现步骤、预期与实际结果等" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") + .flex.items-center.justify-between + button(type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors") 提交反馈 + a(href="mailto:me@xieyaxin.top" class="text-sm text-gray-500 hover:text-blue-600") 或发送邮件联系 + + diff --git a/src/presentation/views/page/extra/help.pug b/src/presentation/views/page/extra/help.pug new file mode 100644 index 0000000..84a8d5d --- /dev/null +++ b/src/presentation/views/page/extra/help.pug @@ -0,0 +1,97 @@ +extends /layouts/empty.pug + +block pageHead + +block pageContent + .help.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 帮助中心 + p(class="text-gray-600 mb-8 text-center text-lg") 欢迎使用帮助中心,这里为您提供完整的使用指南和问题解答 + + // 快速入门 + .help-section(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center") + span(class="mr-2") 🚀 + | 快速入门 + .grid.grid-cols-1(class="md:grid-cols-2 gap-4") + .help-card(class="p-4 bg-blue-50 rounded-lg border border-blue-200") + h3(class="font-semibold text-blue-800 mb-2") 注册登录 + p(class="text-sm text-gray-700") 点击右上角"注册"按钮,填写基本信息即可创建账户 + .help-card(class="p-4 bg-green-50 rounded-lg border border-green-200") + h3(class="font-semibold text-green-800 mb-2") 浏览文章 + p(class="text-sm text-gray-700") 在首页或文章页面浏览各类精彩内容 + .help-card(class="p-4 bg-purple-50 rounded-lg border border-purple-200") + h3(class="font-semibold text-purple-800 mb-2") 收藏管理 + p(class="text-sm text-gray-700") 点击文章下方的收藏按钮,在个人中心管理收藏 + .help-card(class="p-4 bg-orange-50 rounded-lg border border-orange-200") + h3(class="font-semibold text-orange-800 mb-2") 个人设置 + p(class="text-sm text-gray-700") 在个人中心修改头像、密码等账户信息 + + // 功能指南 + .help-section(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center") + span(class="mr-2") 📚 + | 功能指南 + .help-features(class="space-y-4") + .feature-item(class="p-4 bg-gray-50 rounded-lg") + h3(class="font-semibold text-gray-800 mb-2") 文章阅读 + p(class="text-gray-700 text-sm") 支持多种格式的文章阅读,提供舒适的阅读体验。可以调整字体大小、切换主题等。 + .feature-item(class="p-4 bg-gray-50 rounded-lg") + h3(class="font-semibold text-gray-800 mb-2") 智能搜索 + p(class="text-gray-700 text-sm") 使用关键词搜索文章内容,支持模糊匹配和标签筛选。 + .feature-item(class="p-4 bg-gray-50 rounded-lg") + h3(class="font-semibold text-gray-800 mb-2") 收藏夹 + p(class="text-gray-700 text-sm") 创建个人收藏夹,分类管理感兴趣的内容,支持标签和备注功能。 + + // 常见问题 + .help-section(class="mb-8") + h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center") + span(class="mr-2") ❓ + | 常见问题 + .faq-list(class="space-y-3") + details(class="group") + summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") + | 如何修改密码? + .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") + | 登录后进入个人中心 → 账户安全 → 修改密码,输入原密码和新密码即可。 + + details(class="group") + summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") + | 忘记密码怎么办? + .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") + | 请联系客服协助处理,提供注册时的邮箱或手机号进行身份验证。 + + details(class="group") + summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") + | 如何批量管理收藏? + .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") + | 在个人中心的收藏页面,可以选择多个项目进行批量删除或移动操作。 + + // 联系支持 + .help-section(class="mb-6") + h2(class="text-2xl font-semibold mb-4 text-red-600 flex items-center") + span(class="mr-2") 📞 + | 联系支持 + .support-info(class="grid grid-cols-1 md:grid-cols-3 gap-4") + .support-item(class="text-center p-4 bg-red-50 rounded-lg") + h3(class="font-semibold text-red-800 mb-2") 在线客服 + p(class="text-sm text-gray-700") 工作日 9:00-18:00 + .support-item(class="text-center p-4 bg-red-50 rounded-lg") + h3(class="font-semibold text-red-800 mb-2") 邮箱支持 + p(class="text-sm text-gray-700") support@example.com + .support-item(class="text-center p-4 bg-red-50 rounded-lg") + h3(class="font-semibold text-red-800 mb-2") 反馈建议 + p(class="text-sm text-gray-700") + a(href="/feedback" class="text-blue-600 hover:underline") 意见反馈页面 + + // 相关链接 + .help-links(class="text-center pt-6 border-t border-gray-200") + p(class="text-gray-600 mb-3") 更多帮助资源: + .links(class="flex flex-wrap justify-center gap-4") + a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 + a(href="/terms" class="text-blue-600 hover:text-blue-800 hover:underline") 服务条款 + a(href="/privacy" class="text-blue-600 hover:text-blue-800 hover:underline") 隐私政策 + a(href="/contact" class="text-blue-600 hover:text-blue-800 hover:underline") 联系我们 + + .help-footer(class="text-center mt-8 pt-6 border-t border-gray-200") + p(class="text-gray-500 text-sm") 最后更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 + p(class="text-gray-400 text-xs mt-2") 如有其他问题,欢迎随时联系我们 diff --git a/src/presentation/views/page/extra/privacy.pug b/src/presentation/views/page/extra/privacy.pug new file mode 100644 index 0000000..89927f7 --- /dev/null +++ b/src/presentation/views/page/extra/privacy.pug @@ -0,0 +1,75 @@ +extends /layouts/empty.pug + +block pageContent + .privacy.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-2xl font-bold mb-4") 隐私政策 + p(class="text-gray-600 mb-6") 我们重视您的个人信息与隐私保护。本隐私政策旨在向您说明我们如何收集、使用、共享与保护您的信息,以及您对个人信息享有的权利。请您在使用本站服务前仔细阅读并充分理解本政策的全部内容。 + + h2(class="text-xl font-semibold mt-6 mb-3") 一、适用范围 + ul.list-disc.pl-6.text-gray-700 + li 当您访问、浏览、注册、登录、使用本站提供的各项产品/服务时,本政策适用。 + li 如与《服务条款》存在不一致,以本政策就个人信息处理相关内容为准。 + + h2(class="text-xl font-semibold mt-6 mb-3") 二、我们收集的信息 + p 为向您提供服务与优化体验,我们可能收集以下信息: + ul.list-disc.pl-6.text-gray-700 + li 账户信息:昵称、头像、联系方式(如邮箱、手机)、密码或凭证等。 + li 使用信息:访问记录、点击行为、浏览历史、设备信息(设备型号、操作系统、浏览器类型、分辨率)、网络信息(IP、运营商)。 + li 日志信息:错误日志、性能日志、系统事件,以便排查问题和提升稳定性。 + li 交互信息:您与我们沟通时提交的反馈、工单、评论与表单信息。 + + h2(class="text-xl font-semibold mt-6 mb-3") 三、信息的来源与收集方式 + ul.list-disc.pl-6.text-gray-700 + li 您主动提供:注册、填写表单、提交反馈时提供的相关信息。 + li 自动收集:通过 Cookie/本地存储、日志与统计分析工具自动采集的使用数据与设备信息。 + li 第三方来源:在您授权的前提下,我们可能从依法合规的第三方获取必要信息以完善服务。 + + h2(class="text-xl font-semibold mt-6 mb-3") 四、我们如何使用信息 + ul.list-disc.pl-6.text-gray-700 + li 提供、维护与优化产品/服务的功能与性能。 + li 账号管理、身份验证、安全防护与风险控制。 + li 向您发送与服务相关的通知(如更新、变更、异常提示)。 + li 数据统计与分析,用于改善产品体验与用户支持。 + li 依法需配合的监管合规、争议处理与维权。 + + h2(class="text-xl font-semibold mt-6 mb-3") 五、Cookie 与本地存储 + p 为确保基础功能和提升体验,我们会使用 Cookie 或浏览器本地存储: + ul.list-disc.pl-6.text-gray-700 + li 目的:会话保持、偏好设置、性能与功能分析。 + li 管理:您可在浏览器设置中清除或禁止 Cookie;但部分功能可能因此受限或不可用。 + + h2(class="text-xl font-semibold mt-6 mb-3") 六、信息共享、转让与公开披露 + ul.list-disc.pl-6.text-gray-700 + li 我们不会向无关第三方出售您的个人信息。 + li 仅在以下情形共享或转让:获得您明确同意;基于法律法规、司法或行政机关要求;为实现功能所必需的可信合作伙伴(最小必要原则并签署保密与数据保护协议)。 + li 公开披露仅在法律要求或为保护重大公共利益、他人生命财产安全等必要情形下进行。 + + h2(class="text-xl font-semibold mt-6 mb-3") 七、第三方服务与 SDK + p 本站可能集成第三方服务(如统计分析、支付、登录、地图等)。第三方可能独立收集、处理您的信息,其行为受其自身隐私政策约束。我们将审慎评估接入必要性并尽可能要求其遵循最小必要、去标识化与安全合规。 + + h2(class="text-xl font-semibold mt-6 mb-3") 八、信息的存储与安全 + ul.list-disc.pl-6.text-gray-700 + li 存储地点与期限:信息通常存储在依法设立的服务器中,保存期限以实现目的所需的最短时间为准,法律法规另有要求的从其规定。 + li 安全措施:采用访问控制、加密传输/存储、最小权限、定期审计与备份等措施,降低信息泄露、损毁、丢失风险。 + li 事件响应:一旦发生安全事件,我们将按照法律法规履行告知与处置义务。 + + h2(class="text-xl font-semibold mt-6 mb-3") 九、您的权利 + ul.list-disc.pl-6.text-gray-700 + li 访问、更正与删除:在不影响其他自然人合法权益及法律留存要求的前提下,您可按照指引访问、更正或删除相关信息。 + li 撤回同意与注销账户:您可撤回非必要信息处理的授权,或申请注销账户(法律法规另有规定或为履行合同所必需的除外)。 + li 获取副本与可携权(如适用):在符合法律条件时,您可请求导出个人信息副本。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十、未成年人保护 + p 未成年人应在监护人监护、指导下使用本站服务。若您是监护人并对未成年人信息有疑问,请与我们联系,我们将在核实后尽快处理。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十一、跨境传输(如适用) + p 如涉及将您的个人信息传输至境外,我们会依据适用法律履行必要评估、备案与合同保障义务,并确保接收方具备足够的数据保护能力。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十二、本政策的更新 + p 为适应业务、技术或法律法规变化,我们可能对本政策进行更新。重大变更将以显著方式提示。您继续使用服务即视为接受更新后的政策。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十三、联系我们 + p 如您对本政策或个人信息保护有任何疑问、意见或请求,请通过页脚中的联系方式与我们取得联系,我们将尽快予以回复。 + + p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 + diff --git a/src/presentation/views/page/extra/terms.pug b/src/presentation/views/page/extra/terms.pug new file mode 100644 index 0000000..a64d456 --- /dev/null +++ b/src/presentation/views/page/extra/terms.pug @@ -0,0 +1,64 @@ +extends /layouts/empty.pug + +block pageContent + .terms.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") + h1(class="text-2xl font-bold mb-4") 服务条款 + p(class="text-gray-600 mb-6") 欢迎使用本网站与相关服务。为保障您的合法权益,请在使用前仔细阅读并充分理解本服务条款。 + + h2(class="text-xl font-semibold mt-6 mb-3") 一、协议的接受与变更 + p 本条款构成您与本站之间就使用本站服务所达成的协议。一旦您访问或使用本站,即视为您已阅读并同意受本条款约束。本站有权根据业务需要对条款进行修订,修订后的条款将通过页面公示或其他适当方式通知,若您继续使用服务,即视为接受修订内容。 + + h2(class="text-xl font-semibold mt-6 mb-3") 二、账户注册与使用 + ul.list-disc.pl-6.text-gray-700 + li 您应当具备完全民事行为能力;如不具备,请确保在监护人指导下使用。 + li 注册信息应真实、准确、完整,并在变更时及时更新。 + li 您应妥善保管账户与密码,因保管不善导致的损失由您自行承担。 + li 发现任何未经授权的使用行为,请立即与我们联系。 + + h2(class="text-xl font-semibold mt-6 mb-3") 三、用户行为规范 + ul.list-disc.pl-6.text-gray-700 + li 遵守法律法规,不得利用本站制作、复制、发布、传播违法违规内容。 + li 不得干扰、破坏本站正常运营,不得进行未经授权的访问、抓取或数据采集。 + li 不得对本站进行逆向工程、反编译或尝试获取源代码。 + li 尊重他人合法权益,不得侵犯他人知识产权、隐私权、名誉权等。 + + h2(class="text-xl font-semibold mt-6 mb-3") 四、内容与知识产权 + ul.list-disc.pl-6.text-gray-700 + li 除非另有说明,本站及其内容(包括但不限于文字、图片、界面、版式、程序、数据等)受相关法律保护。 + li 未经授权,任何人不得以任何方式使用、复制、修改、传播或用于商业目的。 + li 用户在本站发布或上传的内容,用户应保证拥有相应权利且不侵犯任何第三方权益。 + + h2(class="text-xl font-semibold mt-6 mb-3") 五、隐私与数据保护 + p 我们将依法收集、使用、存储与保护您的个人信息。更多细则请参见 + a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 + | 。 + + h2(class="text-xl font-semibold mt-6 mb-3") 六、第三方服务 + p 本站可能集成或链接第三方服务。您对第三方服务的使用应遵循其各自的条款与政策,由此产生的纠纷与责任由您与第三方自行解决。 + + h2(class="text-xl font-semibold mt-6 mb-3") 七、服务变更、中断与终止 + ul.list-disc.pl-6.text-gray-700 + li 因系统维护、升级或不可抗力等原因,本站有权对服务进行变更、中断或终止。 + li 对于免费服务,本站不对中断或终止承担任何赔偿责任;对付费服务,将依据法律法规与约定处理。 + + h2(class="text-xl font-semibold mt-6 mb-3") 八、免责声明 + ul.list-disc.pl-6.text-gray-700 + li 本站以“现状”与“可得”基础提供服务,不对服务的准确性、完整性、持续性做出明示或暗示保证。 + li 因网络故障、设备故障、黑客攻击、不可抗力等造成的服务中断或数据丢失,本站不承担由此产生的损失责任。 + + h2(class="text-xl font-semibold mt-6 mb-3") 九、违约处理 + p 如您违反本条款或相关法律法规,本站有权采取包括但不限于限制功能、冻结或注销账号、删除内容、追究法律责任等措施。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十、适用法律与争议解决 + p 本条款的订立、执行与解释及争议的解决,适用中华人民共和国法律。因本条款产生的任何争议,双方应友好协商解决;协商不成的,提交本站所在地有管辖权的人民法院诉讼解决。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十一、未成年人保护 + p 未成年人应在监护人监护、指导下使用本站服务。监护人应承担监护责任,合理监督未成年人上网行为。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十二、条款的可分割性 + p 如本条款任何条款被认定为无效或不可执行,其余条款仍然有效并对双方具有约束力。 + + h2(class="text-xl font-semibold mt-6 mb-3") 十三、联系与通知 + p 如您对本条款有任何疑问或需要联系本站,请通过页脚中的联系方式与我们取得联系。 + + p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 diff --git a/src/presentation/views/page/index copy/index.pug b/src/presentation/views/page/index copy/index.pug new file mode 100644 index 0000000..97b371c --- /dev/null +++ b/src/presentation/views/page/index copy/index.pug @@ -0,0 +1,10 @@ +extends /layouts/page.pug + +block pageHead + +css("css/page/index.css") + +block pageContent + .card.home-hero + h1 #{$site.site_title} + p.subtitle #{$site.site_description} + diff --git a/src/presentation/views/page/index/index copy 2.pug b/src/presentation/views/page/index/index copy 2.pug new file mode 100644 index 0000000..c7ce24a --- /dev/null +++ b/src/presentation/views/page/index/index copy 2.pug @@ -0,0 +1,11 @@ +extends /layouts/bg-page.pug + +block pageHead + +css("css/page/index.css") + +block pageContent + div(class="mt-[20px]") + +include() + include /htmx/navbar.pug + .card(class="mt-[20px]") + img(src="/static/bg2.webp" alt="bg") \ No newline at end of file diff --git a/src/presentation/views/page/index/index copy.pug b/src/presentation/views/page/index/index copy.pug new file mode 100644 index 0000000..6c53ce1 --- /dev/null +++ b/src/presentation/views/page/index/index copy.pug @@ -0,0 +1,17 @@ +extends /layouts/pure.pug + +block pageHead + +css("css/page/index.css") + +block pageContent + .home-hero + .avatar-container + .author #{$site.site_author} + img.avatar(src=$site.site_author_avatar, alt="") + .card + div 人生轨迹 + +include() + - var timeLine = [{icon: "第一份工作",title: "???", desc: `做游戏的。`, } ] + include /htmx/timeline.pug + //- div(hx-get="/htmx/timeline" hx-trigger="load") + //- div(style="text-align:center;color:white") Loading diff --git a/src/presentation/views/page/index/index.pug b/src/presentation/views/page/index/index.pug new file mode 100644 index 0000000..c543dd2 --- /dev/null +++ b/src/presentation/views/page/index/index.pug @@ -0,0 +1,69 @@ +extends /layouts/empty.pug + +block pageHead + +css('css/page/index.css') + +css('https://unpkg.com/tippy.js@5/dist/backdrop.css') + +js("https://unpkg.com/popper.js@1") + +js("https://unpkg.com/tippy.js@5") + +mixin item(url, desc) + a(href=url target="_blank" class="inline-flex items-center text-[16px] p-[10px] rounded-[10px] shadow") + block + .material-symbols-light--info-rounded(data-tippy-content=desc) + +mixin card(blog) + .article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100") + h3.article-title(class="text-lg font-semibold text-gray-900 mb-2") + div(class="transition-colors duration-200") #{blog.title} + if blog.status === "draft" + span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 + p.article-meta(class="text-sm text-gray-400 mb-3 flex") + span(class="mr-2 line-clamp-1" title=blog.author) + span 作者: + span(class="transition-colors duration-200") #{blog.author} + span(class="mr-2 whitespace-nowrap") + span | + span(class="transition-colors duration-200") #{blog.updated_at.slice(0, 10)} + span(class="mr-2 whitespace-nowrap") + span | 分类: + a(href=`/articles/category/${blog.category}` class="hover:text-blue-600 transition-colors duration-200") #{blog.category} + p.article-desc( + class="text-gray-600 text-base mb-4 line-clamp-2" + style="height: 2.8em; overflow: hidden;" + ) + | #{blog.description} + a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → + +mixin empty() + .div-placeholder(class="h-[100px] w-full bg-gray-100 text-center flex items-center justify-center text-[16px]") + block + +block pageContent + div + h2(class="text-[20px] font-bold mb-[10px]") 接口列表 + if apiList && apiList.length > 0 + .api.list + each api in apiList + +item(api.url, api.desc) #{api.name} + else + +empty() 空 + div(class="mt-[20px]") + h2(class="text-[20px] font-bold mb-[10px]") 文章列表 + if blogs && blogs.length > 0 + .blog.list + each blog in blogs + +card(blog) + else + +empty() 文章数据为空 + div(class="mt-[20px]") + h2(class="text-[20px] font-bold mb-[10px]") 收藏列表 + if collections && collections.length > 0 + .blog.list + each collection in collections + +card(collection) + else + +empty() 收藏列表数据为空 + +block pageScripts + script. + tippy('[data-tippy-content]'); \ No newline at end of file diff --git a/src/presentation/views/page/index/person.pug b/src/presentation/views/page/index/person.pug new file mode 100644 index 0000000..a78eb26 --- /dev/null +++ b/src/presentation/views/page/index/person.pug @@ -0,0 +1,9 @@ +extends /layouts/pure.pug + +block pageHead + +css("css/page/index.css") + +block pageContent + +include() + - let timeLine = [{icon: '11',title: "aaaa",desc:"asd"}] + include /htmx/timeline.pug \ No newline at end of file diff --git a/src/presentation/views/page/login/index.pug b/src/presentation/views/page/login/index.pug new file mode 100644 index 0000000..796f94f --- /dev/null +++ b/src/presentation/views/page/login/index.pug @@ -0,0 +1,19 @@ +extends /layouts/empty.pug + +block pageScripts + script(src="js/login.js") + +block pageContent + .flex.items-center.justify-center.bg-base-200.h-full + .w-full.max-w-md.bg-base-100.shadow-xl.rounded-xl.p-8 + h2.text-2xl.font-bold.text-center.mb-6.text-base-content 登录 + form#login-form(action="/login" method="post" class="space-y-5") + .form-group + label(for="username" class="block mb-1 text-base-content") 用户名 + input#username(type="text" name="username" placeholder="请输入用户名" required class="input input-bordered w-full") + .form-group + label(for="password" class="block mb-1 text-base-content") 密码 + input#password(type="password" name="password" placeholder="请输入密码" required class="input input-bordered w-full") + button.login-btn(type="submit" class="btn btn-primary w-full") 登录 + if error + .login-error.mt-4.text-error.text-center= error diff --git a/src/presentation/views/page/notice/index.pug b/src/presentation/views/page/notice/index.pug new file mode 100644 index 0000000..ae96700 --- /dev/null +++ b/src/presentation/views/page/notice/index.pug @@ -0,0 +1,7 @@ +extends /layouts/empty.pug + +block pageHead + + +block pageContent + div 这里是通知界面 \ No newline at end of file diff --git a/src/presentation/views/page/profile/index.pug b/src/presentation/views/page/profile/index.pug new file mode 100644 index 0000000..f0fc9d0 --- /dev/null +++ b/src/presentation/views/page/profile/index.pug @@ -0,0 +1,625 @@ +extends /layouts/empty.pug + +block pageHead + style. + .profile-container { + max-width: 1200px; + margin: 20px auto; + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0,0,0,0.1); + overflow: hidden; + display: flex; + min-height: 600px; + } + + .profile-sidebar { + width: 320px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 40px 24px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + + .profile-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + background: rgba(255,255,255,0.2); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; + border: 4px solid rgba(255,255,255,0.3); + overflow: hidden; + } + + .profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + } + + .profile-avatar .avatar-placeholder { + font-size: 3rem; + color: rgba(255,255,255,0.8); + } + + .profile-name { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 8px 0; + } + + .profile-username { + font-size: 1rem; + opacity: 0.9; + margin: 0 0 16px 0; + background: rgba(255,255,255,0.2); + padding: 6px 16px; + border-radius: 20px; + } + + .profile-bio { + font-size: 0.9rem; + opacity: 0.8; + line-height: 1.5; + margin: 0; + max-width: 250px; + } + + .profile-stats { + margin-top: 32px; + width: 100%; + } + + .stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid rgba(255,255,255,0.2); + } + + .stat-item:last-child { + border-bottom: none; + } + + .stat-label { + font-size: 0.85rem; + opacity: 0.8; + } + + .stat-value { + font-weight: 600; + font-size: 0.9rem; + } + + .profile-main { + flex: 1; + padding: 40px 32px; + background: #f8fafc; + } + + .profile-header { + margin-bottom: 32px; + } + + .main-title { + font-size: 2rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 8px 0; + } + + .main-subtitle { + color: #64748b; + font-size: 1rem; + margin: 0; + } + + // 标签页样式 + .profile-tabs { + background: white; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + border: 1px solid #e2e8f0; + overflow: hidden; + } + + .tab-nav { + display: flex; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + } + + .tab-btn { + flex: 1; + padding: 16px 24px; + background: none; + border: none; + font-size: 1rem; + font-weight: 500; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + } + + .tab-btn:hover { + background: #f1f5f9; + color: #334155; + } + + .tab-btn.active { + background: white; + color: #1e293b; + font-weight: 600; + } + + .tab-btn.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + } + + .tab-content { + padding: 32px; + } + + .tab-pane { + display: none; + } + + .tab-pane.active { + display: block; + } + + .profile-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + } + + .profile-section { + background: white; + border-radius: 12px; + padding: 28px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + border: 1px solid #e2e8f0; + } + + .section-title { + font-size: 1.25rem; + font-weight: 600; + color: #1e293b; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 2px solid #e2e8f0; + position: relative; + display: flex; + align-items: center; + } + + .section-title::before { + content: ''; + width: 4px; + height: 20px; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + border-radius: 2px; + margin-right: 12px; + } + + .form-group { + margin-bottom: 20px; + } + + .form-group:last-child { + margin-bottom: 0; + } + + .form-label { + display: block; + margin-bottom: 8px; + color: #374151; + font-size: 0.9rem; + font-weight: 500; + } + + .form-input, + .form-textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + background: #f9fafb; + transition: all 0.2s ease; + box-sizing: border-box; + } + + .form-input:focus, + .form-textarea:focus { + border-color: #667eea; + outline: none; + background: #fff; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + } + + .form-textarea { + resize: vertical; + min-height: 100px; + font-family: inherit; + } + + .form-actions { + display: flex; + gap: 12px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + } + + .btn { + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 100px; + } + + .btn-primary { + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + } + + .btn-secondary { + background: #6b7280; + color: white; + } + + .btn-secondary:hover { + background: #4b5563; + transform: translateY(-1px); + } + + .info-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-top: 20px; + } + + .info-item { + background: #f8fafc; + padding: 16px; + border-radius: 8px; + border: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + } + + .info-label { + font-size: 0.875rem; + color: #64748b; + } + + .info-value { + font-size: 0.9rem; + color: #1e293b; + font-weight: 500; + } + + .message { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-weight: 500; + display: none; + } + + .message.show { + display: block !important; + } + + .message-container { + margin-bottom: 16px; + } + + .message.success { + background-color: #d1fae5; + color: #065f46; + border: 1px solid #a7f3d0; + } + + .message.error { + background-color: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; + } + + .message.info { + background-color: #dbeafe; + color: #1e40af; + border: 1px solid #bfdbfe; + } + + .loading { + opacity: 0.6; + pointer-events: none; + } + + .loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid #f3f3f3; + border-top: 2px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .form-input.error, + .form-textarea.error { + border-color: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); + } + + .error-message { + color: #ef4444; + font-size: 0.8rem; + margin-top: 6px; + display: none; + } + + .error-message.show { + display: block; + } + + @media (max-width: 1024px) { + .profile-container { + flex-direction: column; + margin: 20px; + } + + .profile-sidebar { + width: 100%; + padding: 32px 24px; + } + + .profile-content { + grid-template-columns: 1fr; + gap: 24px; + } + + .profile-main { + padding: 32px 24px; + } + } + + @media (max-width: 768px) { + .profile-container { + margin: 16px; + border-radius: 12px; + } + + .profile-sidebar { + padding: 24px 20px; + } + + .profile-main { + padding: 24px 20px; + } + + .profile-content { + gap: 20px; + } + + .profile-section { + padding: 24px 20px; + } + + .form-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } + } + +block pageContent + form(action="/profile/upload-avatar" method="post" enctype="multipart/form-data") + input(type="file", name="avatar", accept="image/*" onchange="document.getElementById('upload-btn').click()") + button#upload-btn(type="submit") 上传头像 + .profile-container + .profile-sidebar + .profile-avatar + if user.avatar + img(src=user.avatar alt="用户头像") + else + .avatar-placeholder 👤 + + h2.profile-name #{user.name || user.username || '用户'} + .profile-username @#{user.username || 'username'} + + if user.bio + p.profile-bio #{user.bio} + else + p.profile-bio 这个人很懒,还没有写个人简介... + + .profile-stats + .stat-item + span.stat-label 用户ID + span.stat-value #{user.id || 'N/A'} + + .stat-item + span.stat-label 注册时间 + span.stat-value #{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : 'N/A'} + + .stat-item + span.stat-label 用户角色 + span.stat-value #{user.role || 'user'} + + .profile-main + .profile-header + h1.main-title 个人资料设置 + p.main-subtitle 管理您的个人信息和账户安全 + + .profile-tabs + .tab-nav + button.tab-btn.active(data-tab="basic") 基本信息 + button.tab-btn(data-tab="security") 账户安全 + + .tab-content + // 基本信息标签页 + .tab-pane.active#basic-tab + .profile-section + h2.section-title 基本信息 + form#profileForm(action="/profile/update", method="POST") + // 消息提示区域 + .message-container + .message.success#profileMessage + span 资料更新成功! + button.message-close(type="button" onclick="closeMessage('profileMessage')") × + .message.error#profileError + span#profileErrorMessage 更新失败,请重试 + button.message-close(type="button" onclick="closeMessage('profileError')") × + + .form-group + label.form-label(for="username") 用户名 * + input.form-input#username( + type="text" + name="username" + value=user.username || '' + required + placeholder="请输入用户名" + ) + .error-message#username-error + + .form-group + label.form-label(for="name") 昵称 + input.form-input#name( + type="text" + name="name" + value=user.name || '' + placeholder="请输入昵称" + ) + + .form-group + label.form-label(for="email") 邮箱 + input.form-input#email( + type="email" + name="email" + value=user.email || '' + placeholder="请输入邮箱地址" + ) + .error-message#email-error + + .form-group + label.form-label(for="bio") 个人简介 + textarea.form-textarea#bio( + name="bio" + placeholder="介绍一下自己..." + )= user.bio || '' + + .form-group + label.form-label(for="avatar") 头像URL + input.form-input#avatar( + type="url" + name="avatar" + value=user.avatar || '' + placeholder="请输入头像图片链接" + ) + + .form-actions + button.btn.btn-primary(type="submit") 保存更改 + button.btn.btn-secondary(type="button" onclick="resetForm()") 重置 + + // 账户安全标签页 + .tab-pane#security-tab + .profile-section + h2.section-title 账户安全 + + // 修改密码 + form#passwordForm(action="/profile/change-password", method="POST") + // 消息提示区域 + .message-container + .message.success#passwordMessage + span 密码修改成功! + button.message-close(type="button" onclick="closeMessage('passwordMessage')") × + .message.error#passwordError + span#passwordErrorMessage 密码修改失败,请重试 + button.message-close(type="button" onclick="closeMessage('passwordError')") × + + .form-group + label.form-label(for="oldPassword") 当前密码 * + input.form-input#oldPassword( + type="password" + name="oldPassword" + required + placeholder="请输入当前密码" + ) + + .form-group + label.form-label(for="newPassword") 新密码 * + input.form-input#newPassword( + type="password" + name="newPassword" + required + placeholder="请输入新密码(至少6位)" + minlength="6" + ) + + .form-group + label.form-label(for="confirmPassword") 确认新密码 * + input.form-input#confirmPassword( + type="password" + name="confirmPassword" + required + placeholder="请再次输入新密码" + minlength="6" + ) + + .form-actions + button.btn.btn-primary(type="submit") 修改密码 + button.btn.btn-secondary(type="button" onclick="resetPasswordForm()") 清空 + + // 账户信息 + .info-grid + .info-item + span.info-label 最后更新 + span.info-value #{user.updated_at ? new Date(user.updated_at).toLocaleDateString('zh-CN') : 'N/A'} + +block pageScripts + script(src="/js/profile.js") \ No newline at end of file diff --git a/src/presentation/views/page/register/index.pug b/src/presentation/views/page/register/index.pug new file mode 100644 index 0000000..1af0613 --- /dev/null +++ b/src/presentation/views/page/register/index.pug @@ -0,0 +1,119 @@ +extends /layouts/empty.pug + +block pageHead + style. + body { + background: #f5f7fa; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + } + .register-container { + max-width: 400px; + margin: 60px auto; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 16px rgba(0,0,0,0.08); + padding: 32px 28px 24px 28px; + } + .register-title { + text-align: center; + font-size: 2rem; + margin-bottom: 24px; + color: #333; + font-weight: 600; + } + .form-group { + margin-bottom: 18px; + } + label { + display: block; + margin-bottom: 6px; + color: #555; + font-size: 1rem; + } + input[type="text"], + input[type="email"], + input[type="password"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1rem; + background: #f9fafb; + transition: border 0.2s; + box-sizing: border-box; + } + input:focus { + border-color: #409eff; + outline: none; + } + .register-btn { + width: 100%; + padding: 12px 0; + background: linear-gradient(90deg, #409eff 0%, #66b1ff 100%); + color: #fff; + border: none; + border-radius: 6px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + margin-top: 10px; + transition: background 0.2s; + } + .register-btn:hover { + background: linear-gradient(90deg, #66b1ff 0%, #409eff 100%); + } + .login-link { + display: block; + text-align: right; + margin-top: 14px; + color: #409eff; + text-decoration: none; + font-size: 0.95rem; + } + .login-link:hover { + text-decoration: underline; + } + .captcha-container { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + } + .captcha-container img { + width: 100px; + height: 30px; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + } + .captcha-container img:hover { + border-color: #409eff; + box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1); + } + .captcha-container input { + flex: 1; + margin-bottom: 0; + } + +block pageContent + .register-container + .register-title 注册账号 + form(action="/register" method="post") + .form-group + label(for="username") 用户名 + input(type="text" id="username" name="username" required placeholder="请输入用户名") + .form-group + label(for="password") 密码 + input(type="password" id="password" name="password" required placeholder="请输入密码") + .form-group + label(for="confirm_password") 确认密码 + input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码") + .form-group + label(for="code") 验证码 + .captcha-container + img#captcha-img(src="/captcha", alt="验证码" title="点击刷新验证码") + input(type="text" id="code" name="code" required placeholder="请输入验证码") + script(src="/js/register.js") + button.register-btn(type="submit") 注册 + a.login-link(href="/login") 已有账号?去登录 \ No newline at end of file diff --git a/src/services/ArticleService.js b/src/services/ArticleService.js deleted file mode 100644 index 1364348..0000000 --- a/src/services/ArticleService.js +++ /dev/null @@ -1,295 +0,0 @@ -import ArticleModel from "db/models/ArticleModel.js" -import CommonError from "utils/error/CommonError.js" - -class ArticleService { - // 获取所有文章 - async getAllArticles() { - try { - return await ArticleModel.findAll() - } catch (error) { - throw new CommonError(`获取文章列表失败: ${error.message}`) - } - } - - // 获取已发布的文章 - async getPublishedArticles() { - try { - return await ArticleModel.findPublished() - } catch (error) { - throw new CommonError(`获取已发布文章失败: ${error.message}`) - } - } - - // 获取草稿文章 - async getDraftArticles() { - try { - return await ArticleModel.findDrafts() - } catch (error) { - throw new CommonError(`获取草稿文章失败: ${error.message}`) - } - } - - // 根据ID获取文章 - async getArticleById(id) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - return article - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取文章失败: ${error.message}`) - } - } - - // 根据slug获取文章 - async getArticleBySlug(slug) { - try { - const article = await ArticleModel.findBySlug(slug) - if (!article) { - throw new CommonError("文章不存在") - } - return article - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取文章失败: ${error.message}`) - } - } - - // 根据作者获取文章 - async getArticlesByAuthor(author) { - try { - return await ArticleModel.findByAuthor(author) - } catch (error) { - throw new CommonError(`获取作者文章失败: ${error.message}`) - } - } - - // 根据分类获取文章 - async getArticlesByCategory(category) { - try { - return await ArticleModel.findByCategory(category) - } catch (error) { - throw new CommonError(`获取分类文章失败: ${error.message}`) - } - } - - // 根据标签获取文章 - async getArticlesByTags(tags) { - try { - return await ArticleModel.findByTags(tags) - } catch (error) { - throw new CommonError(`获取标签文章失败: ${error.message}`) - } - } - - // 关键词搜索文章 - async searchArticles(keyword) { - try { - if (!keyword || keyword.trim() === '') { - throw new CommonError("搜索关键词不能为空") - } - return await ArticleModel.searchByKeyword(keyword.trim()) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`搜索文章失败: ${error.message}`) - } - } - - // 创建文章 - async createArticle(data) { - try { - if (!data.title || !data.content) { - throw new CommonError("标题和内容为必填字段") - } - return await ArticleModel.create(data) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`创建文章失败: ${error.message}`) - } - } - - // 更新文章 - async updateArticle(id, data) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - return await ArticleModel.update(id, data) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`更新文章失败: ${error.message}`) - } - } - - // 删除文章 - async deleteArticle(id) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - return await ArticleModel.delete(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`删除文章失败: ${error.message}`) - } - } - - // 发布文章 - async publishArticle(id) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - if (article.status === 'published') { - throw new CommonError("文章已经是发布状态") - } - return await ArticleModel.publish(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`发布文章失败: ${error.message}`) - } - } - - // 取消发布文章 - async unpublishArticle(id) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - if (article.status === 'draft') { - throw new CommonError("文章已经是草稿状态") - } - return await ArticleModel.unpublish(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`取消发布文章失败: ${error.message}`) - } - } - - // 增加文章阅读量 - async incrementViewCount(id) { - try { - const article = await ArticleModel.findById(id) - if (!article) { - throw new CommonError("文章不存在") - } - return await ArticleModel.incrementViewCount(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`增加阅读量失败: ${error.message}`) - } - } - - // 根据日期范围获取文章 - async getArticlesByDateRange(startDate, endDate) { - try { - if (!startDate || !endDate) { - throw new CommonError("开始日期和结束日期不能为空") - } - return await ArticleModel.findByDateRange(startDate, endDate) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取日期范围文章失败: ${error.message}`) - } - } - - // 获取文章统计信息 - async getArticleStats() { - try { - const [totalCount, publishedCount, categoryStats, statusStats] = await Promise.all([ - ArticleModel.getArticleCount(), - ArticleModel.getPublishedArticleCount(), - ArticleModel.getArticleCountByCategory(), - ArticleModel.getArticleCountByStatus() - ]) - - return { - total: totalCount, - published: publishedCount, - draft: totalCount - publishedCount, - byCategory: categoryStats, - byStatus: statusStats - } - } catch (error) { - throw new CommonError(`获取文章统计失败: ${error.message}`) - } - } - - // 获取最近文章 - async getRecentArticles(limit = 10) { - try { - return await ArticleModel.getRecentArticles(limit) - } catch (error) { - throw new CommonError(`获取最近文章失败: ${error.message}`) - } - } - - // 获取热门文章 - async getPopularArticles(limit = 10) { - try { - return await ArticleModel.getPopularArticles(limit) - } catch (error) { - throw new CommonError(`获取热门文章失败: ${error.message}`) - } - } - - // 获取精选文章 - async getFeaturedArticles(limit = 5) { - try { - return await ArticleModel.getFeaturedArticles(limit) - } catch (error) { - throw new CommonError(`获取精选文章失败: ${error.message}`) - } - } - - // 获取相关文章 - async getRelatedArticles(articleId, limit = 5) { - try { - const article = await ArticleModel.findById(articleId) - if (!article) { - throw new CommonError("文章不存在") - } - return await ArticleModel.getRelatedArticles(articleId, limit) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取相关文章失败: ${error.message}`) - } - } - - // 分页获取文章 - async getArticlesWithPagination(page = 1, pageSize = 10, status = 'published') { - try { - let query = ArticleModel.findPublished() - if (status === 'all') { - query = ArticleModel.findAll() - } else if (status === 'draft') { - query = ArticleModel.findDrafts() - } - - const offset = (page - 1) * pageSize - const articles = await query.limit(pageSize).offset(offset) - const total = await ArticleModel.getPublishedArticleCount() - - return { - articles, - pagination: { - current: page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize) - } - } - } catch (error) { - throw new CommonError(`分页获取文章失败: ${error.message}`) - } - } -} - -export default ArticleService -export { ArticleService } diff --git a/src/services/BookmarkService.js b/src/services/BookmarkService.js deleted file mode 100644 index 249591c..0000000 --- a/src/services/BookmarkService.js +++ /dev/null @@ -1,312 +0,0 @@ -import BookmarkModel from "db/models/BookmarkModel.js" -import CommonError from "utils/error/CommonError.js" - -class BookmarkService { - // 获取用户的所有书签 - async getUserBookmarks(userId) { - try { - if (!userId) { - throw new CommonError("用户ID不能为空") - } - return await BookmarkModel.findAllByUser(userId) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取用户书签失败: ${error.message}`) - } - } - - // 根据ID获取书签 - async getBookmarkById(id) { - try { - if (!id) { - throw new CommonError("书签ID不能为空") - } - const bookmark = await BookmarkModel.findById(id) - if (!bookmark) { - throw new CommonError("书签不存在") - } - return bookmark - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取书签失败: ${error.message}`) - } - } - - // 创建书签 - async createBookmark(data) { - try { - if (!data.user_id || !data.url) { - throw new CommonError("用户ID和URL为必填字段") - } - - // 验证URL格式 - if (!this.isValidUrl(data.url)) { - throw new CommonError("URL格式不正确") - } - - return await BookmarkModel.create(data) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`创建书签失败: ${error.message}`) - } - } - - // 更新书签 - async updateBookmark(id, data) { - try { - if (!id) { - throw new CommonError("书签ID不能为空") - } - - const bookmark = await BookmarkModel.findById(id) - if (!bookmark) { - throw new CommonError("书签不存在") - } - - // 如果更新URL,验证格式 - if (data.url && !this.isValidUrl(data.url)) { - throw new CommonError("URL格式不正确") - } - - return await BookmarkModel.update(id, data) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`更新书签失败: ${error.message}`) - } - } - - // 删除书签 - async deleteBookmark(id) { - try { - if (!id) { - throw new CommonError("书签ID不能为空") - } - - const bookmark = await BookmarkModel.findById(id) - if (!bookmark) { - throw new CommonError("书签不存在") - } - - return await BookmarkModel.delete(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`删除书签失败: ${error.message}`) - } - } - - // 根据用户和URL查找书签 - async findBookmarkByUserAndUrl(userId, url) { - try { - if (!userId || !url) { - throw new CommonError("用户ID和URL不能为空") - } - - return await BookmarkModel.findByUserAndUrl(userId, url) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`查找书签失败: ${error.message}`) - } - } - - // 检查书签是否存在 - async isBookmarkExists(userId, url) { - try { - if (!userId || !url) { - return false - } - - const bookmark = await BookmarkModel.findByUserAndUrl(userId, url) - return !!bookmark - } catch (error) { - return false - } - } - - // 批量创建书签 - async createBookmarks(userId, bookmarksData) { - try { - if (!userId || !Array.isArray(bookmarksData) || bookmarksData.length === 0) { - throw new CommonError("用户ID和书签数据不能为空") - } - - const results = [] - const errors = [] - - for (const bookmarkData of bookmarksData) { - try { - const bookmark = await this.createBookmark({ - ...bookmarkData, - user_id: userId - }) - results.push(bookmark) - } catch (error) { - errors.push({ - url: bookmarkData.url, - error: error.message - }) - } - } - - return { - success: results, - errors, - total: bookmarksData.length, - successCount: results.length, - errorCount: errors.length - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`批量创建书签失败: ${error.message}`) - } - } - - // 批量删除书签 - async deleteBookmarks(userId, bookmarkIds) { - try { - if (!userId || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) { - throw new CommonError("用户ID和书签ID列表不能为空") - } - - const results = [] - const errors = [] - - for (const id of bookmarkIds) { - try { - const bookmark = await BookmarkModel.findById(id) - if (bookmark && bookmark.user_id === userId) { - await BookmarkModel.delete(id) - results.push(id) - } else { - errors.push({ - id, - error: "书签不存在或无权限删除" - }) - } - } catch (error) { - errors.push({ - id, - error: error.message - }) - } - } - - return { - success: results, - errors, - total: bookmarkIds.length, - successCount: results.length, - errorCount: errors.length - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`批量删除书签失败: ${error.message}`) - } - } - - // 获取用户书签统计 - async getUserBookmarkStats(userId) { - try { - if (!userId) { - throw new CommonError("用户ID不能为空") - } - - const bookmarks = await BookmarkModel.findAllByUser(userId) - - // 按标签分组统计 - const tagStats = {} - bookmarks.forEach(bookmark => { - if (bookmark.tags) { - const tags = bookmark.tags.split(',').map(tag => tag.trim()) - tags.forEach(tag => { - tagStats[tag] = (tagStats[tag] || 0) + 1 - }) - } - }) - - // 按创建时间分组统计 - const dateStats = {} - bookmarks.forEach(bookmark => { - const date = new Date(bookmark.created_at).toISOString().split('T')[0] - dateStats[date] = (dateStats[date] || 0) + 1 - }) - - return { - total: bookmarks.length, - byTag: tagStats, - byDate: dateStats, - lastUpdated: bookmarks.length > 0 ? bookmarks[0].updated_at : null - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取书签统计失败: ${error.message}`) - } - } - - // 搜索用户书签 - async searchUserBookmarks(userId, keyword) { - try { - if (!userId) { - throw new CommonError("用户ID不能为空") - } - - if (!keyword || keyword.trim() === '') { - return await this.getUserBookmarks(userId) - } - - const bookmarks = await BookmarkModel.findAllByUser(userId) - const searchTerm = keyword.toLowerCase().trim() - - return bookmarks.filter(bookmark => { - return ( - bookmark.title?.toLowerCase().includes(searchTerm) || - bookmark.description?.toLowerCase().includes(searchTerm) || - bookmark.url?.toLowerCase().includes(searchTerm) || - bookmark.tags?.toLowerCase().includes(searchTerm) - ) - }) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`搜索书签失败: ${error.message}`) - } - } - - // 验证URL格式 - isValidUrl(url) { - try { - new URL(url) - return true - } catch { - return false - } - } - - // 获取书签分页 - async getBookmarksWithPagination(userId, page = 1, pageSize = 20) { - try { - if (!userId) { - throw new CommonError("用户ID不能为空") - } - - const allBookmarks = await BookmarkModel.findAllByUser(userId) - const total = allBookmarks.length - const offset = (page - 1) * pageSize - const bookmarks = allBookmarks.slice(offset, offset + pageSize) - - return { - bookmarks, - pagination: { - current: page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize) - } - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`分页获取书签失败: ${error.message}`) - } - } -} - -export default BookmarkService -export { BookmarkService } diff --git a/src/services/JobService.js b/src/services/JobService.js deleted file mode 100644 index 35a04a3..0000000 --- a/src/services/JobService.js +++ /dev/null @@ -1,18 +0,0 @@ -import jobs from "../jobs" - -class JobService { - startJob(id) { - return jobs.start(id) - } - stopJob(id) { - return jobs.stop(id) - } - updateJobCron(id, cronTime) { - return jobs.updateCronTime(id, cronTime) - } - listJobs() { - return jobs.list() - } -} - -export default JobService diff --git a/src/services/README.md b/src/services/README.md deleted file mode 100644 index a9b4f8f..0000000 --- a/src/services/README.md +++ /dev/null @@ -1,222 +0,0 @@ -# 服务层 (Services) - -本目录包含了应用的所有业务逻辑服务层,负责处理业务规则、数据验证和错误处理。 - -## 服务列表 - -### 1. UserService - 用户服务 -处理用户相关的所有业务逻辑,包括用户注册、登录、密码管理等。 - -**主要功能:** -- 用户注册和登录 -- 用户信息管理(增删改查) -- 密码加密和验证 -- 用户统计和搜索 -- 批量操作支持 - -**使用示例:** -```javascript -import { userService } from '../services/index.js' - -// 用户注册 -const newUser = await userService.register({ - username: 'testuser', - email: 'test@example.com', - password: 'password123' -}) - -// 用户登录 -const loginResult = await userService.login({ - username: 'testuser', - password: 'password123' -}) -``` - -### 2. ArticleService - 文章服务 -处理文章相关的所有业务逻辑,包括文章的发布、编辑、搜索等。 - -**主要功能:** -- 文章的增删改查 -- 文章状态管理(草稿/发布) -- 文章搜索和分类 -- 阅读量统计 -- 相关文章推荐 -- 分页支持 - -**使用示例:** -```javascript -import { articleService } from '../services/index.js' - -// 创建文章 -const article = await articleService.createArticle({ - title: '测试文章', - content: '文章内容...', - category: '技术', - tags: 'JavaScript,Node.js' -}) - -// 获取已发布文章 -const publishedArticles = await articleService.getPublishedArticles() - -// 搜索文章 -const searchResults = await articleService.searchArticles('JavaScript') -``` - -### 3. BookmarkService - 书签服务 -处理用户书签的管理,包括添加、编辑、删除和搜索书签。 - -**主要功能:** -- 书签的增删改查 -- URL格式验证 -- 批量操作支持 -- 书签统计和搜索 -- 分页支持 - -**使用示例:** -```javascript -import { bookmarkService } from '../services/index.js' - -// 添加书签 -const bookmark = await bookmarkService.createBookmark({ - user_id: 1, - title: 'Google', - url: 'https://www.google.com', - description: '搜索引擎' -}) - -// 获取用户书签 -const userBookmarks = await bookmarkService.getUserBookmarks(1) - -// 搜索书签 -const searchResults = await bookmarkService.searchUserBookmarks(1, 'Google') -``` - -### 4. SiteConfigService - 站点配置服务 -管理站点的各种配置信息,如站点名称、描述、主题等。 - -**主要功能:** -- 配置的增删改查 -- 配置值验证 -- 批量操作支持 -- 默认配置初始化 -- 配置统计和搜索 - -**使用示例:** -```javascript -import { siteConfigService } from '../services/index.js' - -// 获取配置 -const siteName = await siteConfigService.get('site_name') - -// 设置配置 -await siteConfigService.set('site_name', '我的新网站') - -// 批量设置配置 -await siteConfigService.setMany({ - 'site_description': '网站描述', - 'posts_per_page': 20 -}) - -// 初始化默认配置 -await siteConfigService.initializeDefaultConfigs() -``` - -### 5. JobService - 任务服务 -处理后台任务和定时任务的管理。 - -**主要功能:** -- 任务调度和管理 -- 任务状态监控 -- 任务日志记录 - -## 错误处理 - -所有服务都使用统一的错误处理机制: - -```javascript -import CommonError from 'utils/error/CommonError.js' - -try { - const result = await userService.getUserById(1) -} catch (error) { - if (error instanceof CommonError) { - // 业务逻辑错误 - console.error(error.message) - } else { - // 系统错误 - console.error('系统错误:', error.message) - } -} -``` - -## 数据验证 - -服务层负责数据验证,确保数据的完整性和正确性: - -- **输入验证**:检查必填字段、格式验证等 -- **业务验证**:检查业务规则,如用户名唯一性 -- **权限验证**:确保用户只能操作自己的数据 - -## 事务支持 - -对于涉及多个数据库操作的方法,服务层支持事务处理: - -```javascript -// 在需要事务的方法中使用 -async createUserWithProfile(userData, profileData) { - // 这里可以添加事务支持 - const user = await this.createUser(userData) - // 创建用户档案... - return user -} -``` - -## 缓存策略 - -服务层可以集成缓存机制来提高性能: - -```javascript -// 示例:缓存用户信息 -async getUserById(id) { - const cacheKey = `user:${id}` - let user = await cache.get(cacheKey) - - if (!user) { - user = await UserModel.findById(id) - await cache.set(cacheKey, user, 3600) // 缓存1小时 - } - - return user -} -``` - -## 使用建议 - -1. **控制器层调用服务**:控制器应该调用服务层方法,而不是直接操作模型 -2. **错误处理**:在控制器中捕获服务层抛出的错误并返回适当的HTTP响应 -3. **数据转换**:服务层负责数据格式转换,控制器负责HTTP响应格式 -4. **业务逻辑**:复杂的业务逻辑应该放在服务层,保持控制器的简洁性 - -## 扩展指南 - -添加新的服务: - -1. 创建新的服务文件(如 `NewService.js`) -2. 继承或实现基础服务接口 -3. 在 `index.js` 中导出新服务 -4. 添加相应的测试用例 -5. 更新文档 - -```javascript -// 新服务示例 -class NewService { - async doSomething(data) { - try { - // 业务逻辑 - return result - } catch (error) { - throw new CommonError(`操作失败: ${error.message}`) - } - } -} -``` diff --git a/src/services/SiteConfigService.js b/src/services/SiteConfigService.js deleted file mode 100644 index 59537fd..0000000 --- a/src/services/SiteConfigService.js +++ /dev/null @@ -1,299 +0,0 @@ -import SiteConfigModel from "../db/models/SiteConfigModel.js" -import CommonError from "utils/error/CommonError.js" - -class SiteConfigService { - // 获取指定key的配置 - async get(key) { - try { - if (!key || key.trim() === '') { - throw new CommonError("配置键不能为空") - } - return await SiteConfigModel.get(key.trim()) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取配置失败: ${error.message}`) - } - } - - // 设置指定key的配置 - async set(key, value) { - try { - if (!key || key.trim() === '') { - throw new CommonError("配置键不能为空") - } - if (value === undefined || value === null) { - throw new CommonError("配置值不能为空") - } - return await SiteConfigModel.set(key.trim(), value) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`设置配置失败: ${error.message}`) - } - } - - // 批量获取多个key的配置 - async getMany(keys) { - try { - if (!Array.isArray(keys) || keys.length === 0) { - throw new CommonError("配置键列表不能为空") - } - - // 过滤空值并去重 - const validKeys = [...new Set(keys.filter(key => key && key.trim() !== ''))] - if (validKeys.length === 0) { - throw new CommonError("没有有效的配置键") - } - - return await SiteConfigModel.getMany(validKeys) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`批量获取配置失败: ${error.message}`) - } - } - - // 获取所有配置 - async getAll() { - try { - return await SiteConfigModel.getAll() - } catch (error) { - throw new CommonError(`获取所有配置失败: ${error.message}`) - } - } - - // 删除指定key的配置 - async delete(key) { - try { - if (!key || key.trim() === '') { - throw new CommonError("配置键不能为空") - } - - // 先检查配置是否存在 - const exists = await SiteConfigModel.get(key.trim()) - if (!exists) { - throw new CommonError("配置不存在") - } - - // 这里需要在模型中添加删除方法,暂时返回成功 - // TODO: 在SiteConfigModel中添加delete方法 - return { message: "配置删除成功" } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`删除配置失败: ${error.message}`) - } - } - - // 批量设置配置 - async setMany(configs) { - try { - if (!configs || typeof configs !== 'object') { - throw new CommonError("配置数据格式不正确") - } - - const keys = Object.keys(configs) - if (keys.length === 0) { - throw new CommonError("配置数据不能为空") - } - - const results = [] - const errors = [] - - for (const [key, value] of Object.entries(configs)) { - try { - await this.set(key, value) - results.push(key) - } catch (error) { - errors.push({ - key, - value, - error: error.message - }) - } - } - - return { - success: results, - errors, - total: keys.length, - successCount: results.length, - errorCount: errors.length - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`批量设置配置失败: ${error.message}`) - } - } - - // 获取配置统计信息 - async getConfigStats() { - try { - const allConfigs = await this.getAll() - const keys = Object.keys(allConfigs) - - const stats = { - total: keys.length, - byType: {}, - byLength: { - short: 0, // 0-50字符 - medium: 0, // 51-200字符 - long: 0 // 200+字符 - } - } - - keys.forEach(key => { - const value = allConfigs[key] - const valueType = typeof value - const valueLength = String(value).length - - // 按类型统计 - stats.byType[valueType] = (stats.byType[valueType] || 0) + 1 - - // 按长度统计 - if (valueLength <= 50) { - stats.byLength.short++ - } else if (valueLength <= 200) { - stats.byLength.medium++ - } else { - stats.byLength.long++ - } - }) - - return stats - } catch (error) { - throw new CommonError(`获取配置统计失败: ${error.message}`) - } - } - - // 搜索配置 - async searchConfigs(keyword) { - try { - if (!keyword || keyword.trim() === '') { - return await this.getAll() - } - - const allConfigs = await this.getAll() - const searchTerm = keyword.toLowerCase().trim() - const results = {} - - Object.entries(allConfigs).forEach(([key, value]) => { - if ( - key.toLowerCase().includes(searchTerm) || - String(value).toLowerCase().includes(searchTerm) - ) { - results[key] = value - } - }) - - return results - } catch (error) { - throw new CommonError(`搜索配置失败: ${error.message}`) - } - } - - // 验证配置值 - validateConfigValue(key, value) { - try { - // 根据不同的配置键进行不同的验证 - switch (key) { - case 'site_name': - if (typeof value !== 'string' || value.trim().length === 0) { - throw new CommonError("站点名称必须是有效的字符串") - } - break - case 'site_description': - if (typeof value !== 'string') { - throw new CommonError("站点描述必须是字符串") - } - break - case 'site_url': - try { - new URL(value) - } catch { - throw new CommonError("站点URL格式不正确") - } - break - case 'posts_per_page': - const num = parseInt(value) - if (isNaN(num) || num < 1 || num > 100) { - throw new CommonError("每页文章数必须是1-100之间的数字") - } - break - case 'enable_comments': - if (typeof value !== 'boolean' && !['true', 'false', '1', '0'].includes(String(value))) { - throw new CommonError("评论开关必须是布尔值") - } - break - default: - // 对于其他配置,只做基本类型检查 - if (value === undefined || value === null) { - throw new CommonError("配置值不能为空") - } - } - - return true - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`配置值验证失败: ${error.message}`) - } - } - - // 设置配置(带验证) - async setWithValidation(key, value) { - try { - // 先验证配置值 - this.validateConfigValue(key, value) - - // 验证通过后设置配置 - return await this.set(key, value) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`设置配置失败: ${error.message}`) - } - } - - // 获取默认配置 - getDefaultConfigs() { - return { - site_name: "我的网站", - site_description: "一个基于Koa3的现代化网站", - site_url: "http://localhost:3000", - posts_per_page: 10, - enable_comments: true, - theme: "default", - language: "zh-CN", - timezone: "Asia/Shanghai" - } - } - - // 初始化默认配置 - async initializeDefaultConfigs() { - try { - const defaultConfigs = this.getDefaultConfigs() - const existingConfigs = await this.getAll() - - const configsToSet = {} - Object.entries(defaultConfigs).forEach(([key, value]) => { - if (!(key in existingConfigs)) { - configsToSet[key] = value - } - }) - - if (Object.keys(configsToSet).length > 0) { - await this.setMany(configsToSet) - return { - message: "默认配置初始化成功", - initialized: Object.keys(configsToSet) - } - } - - return { - message: "所有默认配置已存在", - initialized: [] - } - } catch (error) { - throw new CommonError(`初始化默认配置失败: ${error.message}`) - } - } -} - -export default SiteConfigService -export { SiteConfigService } \ No newline at end of file diff --git a/src/services/index.js b/src/services/index.js deleted file mode 100644 index db42d64..0000000 --- a/src/services/index.js +++ /dev/null @@ -1,36 +0,0 @@ -// 服务层统一导出 -import UserService from "./UserService.js" -import ArticleService from "./ArticleService.js" -import BookmarkService from "./BookmarkService.js" -import SiteConfigService from "./SiteConfigService.js" -import JobService from "./JobService.js" - -// 导出所有服务类 -export { - UserService, - ArticleService, - BookmarkService, - SiteConfigService, - JobService -} - -// 导出默认实例(单例模式) -export const userService = new UserService() -export const articleService = new ArticleService() -export const bookmarkService = new BookmarkService() -export const siteConfigService = new SiteConfigService() -export const jobService = new JobService() - -// 默认导出 -export default { - UserService, - ArticleService, - BookmarkService, - SiteConfigService, - JobService, - userService, - articleService, - bookmarkService, - siteConfigService, - jobService -} diff --git a/src/services/userService.js b/src/services/userService.js deleted file mode 100644 index edd9981..0000000 --- a/src/services/userService.js +++ /dev/null @@ -1,414 +0,0 @@ -import UserModel from "db/models/UserModel.js" -import { hashPassword, comparePassword } from "utils/bcrypt.js" -import CommonError from "utils/error/CommonError.js" -import { JWT_SECRET } from "@/middlewares/Auth/auth.js" -import jwt from "@/middlewares/Auth/jwt.js" - -class UserService { - // 根据ID获取用户 - async getUserById(id) { - try { - if (!id) { - throw new CommonError("用户ID不能为空") - } - const user = await UserModel.findById(id) - if (!user) { - throw new CommonError("用户不存在") - } - // 返回脱敏信息 - const { password, ...userInfo } = user - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取用户失败: ${error.message}`) - } - } - - // 获取所有用户 - async getAllUsers() { - try { - const users = await UserModel.findAll() - // 返回脱敏信息 - return users.map(user => { - const { password, ...userInfo } = user - return userInfo - }) - } catch (error) { - throw new CommonError(`获取用户列表失败: ${error.message}`) - } - } - - // 创建新用户 - async createUser(data) { - try { - if (!data.username || !data.password) { - throw new CommonError("用户名和密码为必填字段") - } - - // 检查用户名是否已存在 - const existUser = await UserModel.findByUsername(data.username) - if (existUser) { - throw new CommonError(`用户名${data.username}已存在`) - } - - // 检查邮箱是否已存在 - if (data.email) { - const existEmail = await UserModel.findByEmail(data.email) - if (existEmail) { - throw new CommonError(`邮箱${data.email}已被使用`) - } - } - - // 密码加密 - const hashedPassword = await hashPassword(data.password) - - const user = await UserModel.create({ - ...data, - password: hashedPassword - }) - - // 返回脱敏信息 - const { password, ...userInfo } = Array.isArray(user) ? user[0] : user - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`创建用户失败: ${error.message}`) - } - } - - // 更新用户 - async updateUser(id, data) { - try { - if (!id) { - throw new CommonError("用户ID不能为空") - } - - const user = await UserModel.findById(id) - if (!user) { - throw new CommonError("用户不存在") - } - - // 如果要更新用户名,检查是否重复 - if (data.username && data.username !== user.username) { - const existUser = await UserModel.findByUsername(data.username) - if (existUser) { - throw new CommonError(`用户名${data.username}已存在`) - } - } - - // 如果要更新邮箱,检查是否重复 - if (data.email && data.email !== user.email) { - const existEmail = await UserModel.findByEmail(data.email) - if (existEmail) { - throw new CommonError(`邮箱${data.email}已被使用`) - } - } - - // 如果要更新密码,需要加密 - if (data.password) { - data.password = await hashPassword(data.password) - } - - const updatedUser = await UserModel.update(id, data) - - // 返回脱敏信息 - const { password, ...userInfo } = Array.isArray(updatedUser) ? updatedUser[0] : updatedUser - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`更新用户失败: ${error.message}`) - } - } - - // 删除用户 - async deleteUser(id) { - try { - if (!id) { - throw new CommonError("用户ID不能为空") - } - - const user = await UserModel.findById(id) - if (!user) { - throw new CommonError("用户不存在") - } - - return await UserModel.delete(id) - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`删除用户失败: ${error.message}`) - } - } - - // 注册新用户 - async register(data) { - try { - if (!data.username || !data.password) { - throw new CommonError("用户名和密码不能为空") - } - - // 检查用户名是否已存在 - const existUser = await UserModel.findByUsername(data.username) - if (existUser) { - throw new CommonError(`用户名${data.username}已存在`) - } - - // 检查邮箱是否已存在 - if (data.email) { - const existEmail = await UserModel.findByEmail(data.email) - if (existEmail) { - throw new CommonError(`邮箱${data.email}已被使用`) - } - } - - // 密码加密 - const hashed = await hashPassword(data.password) - - const user = await UserModel.create({ ...data, password: hashed }) - - // 返回脱敏信息 - const { password, ...userInfo } = Array.isArray(user) ? user[0] : user - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`注册失败: ${error.message}`) - } - } - - // 登录 - async login({ username, email, password }) { - try { - if (!password) { - throw new CommonError("密码不能为空") - } - - if (!username && !email) { - throw new CommonError("用户名或邮箱不能为空") - } - - let user - if (username) { - user = await UserModel.findByUsername(username) - } else if (email) { - user = await UserModel.findByEmail(email) - } - - if (!user) { - throw new CommonError("用户不存在") - } - - // 校验密码 - const ok = await comparePassword(password, user.password) - if (!ok) { - throw new CommonError("密码错误") - } - - // 生成token - const token = jwt.sign( - { id: user.id, username: user.username }, - JWT_SECRET, - { expiresIn: "2h" } - ) - - // 返回token和用户信息 - const { password: pwd, ...userInfo } = user - return { token, user: userInfo } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`登录失败: ${error.message}`) - } - } - - // 根据用户名查找用户 - async getUserByUsername(username) { - try { - if (!username) { - throw new CommonError("用户名不能为空") - } - - const user = await UserModel.findByUsername(username) - if (!user) { - throw new CommonError("用户不存在") - } - - // 返回脱敏信息 - const { password, ...userInfo } = user - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取用户失败: ${error.message}`) - } - } - - // 根据邮箱查找用户 - async getUserByEmail(email) { - try { - if (!email) { - throw new CommonError("邮箱不能为空") - } - - const user = await UserModel.findByEmail(email) - if (!user) { - throw new CommonError("用户不存在") - } - - // 返回脱敏信息 - const { password, ...userInfo } = user - return userInfo - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`获取用户失败: ${error.message}`) - } - } - - // 修改密码 - async changePassword(userId, oldPassword, newPassword) { - try { - if (!userId || !oldPassword || !newPassword) { - throw new CommonError("用户ID、旧密码和新密码不能为空") - } - - const user = await UserModel.findById(userId) - if (!user) { - throw new CommonError("用户不存在") - } - - // 验证旧密码 - const isOldPasswordCorrect = await comparePassword(oldPassword, user.password) - if (!isOldPasswordCorrect) { - throw new CommonError("旧密码错误") - } - - // 加密新密码 - const hashedNewPassword = await hashPassword(newPassword) - - // 更新密码 - await UserModel.update(userId, { password: hashedNewPassword }) - - return { message: "密码修改成功" } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`修改密码失败: ${error.message}`) - } - } - - // 重置密码 - async resetPassword(email, newPassword) { - try { - if (!email || !newPassword) { - throw new CommonError("邮箱和新密码不能为空") - } - - const user = await UserModel.findByEmail(email) - if (!user) { - throw new CommonError("用户不存在") - } - - // 加密新密码 - const hashedPassword = await hashPassword(newPassword) - - // 更新密码 - await UserModel.update(user.id, { password: hashedPassword }) - - return { message: "密码重置成功" } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`重置密码失败: ${error.message}`) - } - } - - // 获取用户统计信息 - async getUserStats() { - try { - const users = await UserModel.findAll() - - const stats = { - total: users.length, - active: users.filter(user => user.status === 'active').length, - inactive: users.filter(user => user.status === 'inactive').length, - byRole: {}, - byDate: {} - } - - // 按角色分组统计 - users.forEach(user => { - const role = user.role || 'user' - stats.byRole[role] = (stats.byRole[role] || 0) + 1 - }) - - // 按创建时间分组统计 - users.forEach(user => { - const date = new Date(user.created_at).toISOString().split('T')[0] - stats.byDate[date] = (stats.byDate[date] || 0) + 1 - }) - - return stats - } catch (error) { - throw new CommonError(`获取用户统计失败: ${error.message}`) - } - } - - // 搜索用户 - async searchUsers(keyword) { - try { - if (!keyword || keyword.trim() === '') { - return await this.getAllUsers() - } - - const users = await UserModel.findAll() - const searchTerm = keyword.toLowerCase().trim() - - const filteredUsers = users.filter(user => { - return ( - user.username?.toLowerCase().includes(searchTerm) || - user.email?.toLowerCase().includes(searchTerm) || - user.name?.toLowerCase().includes(searchTerm) - ) - }) - - // 返回脱敏信息 - return filteredUsers.map(user => { - const { password, ...userInfo } = user - return userInfo - }) - } catch (error) { - throw new CommonError(`搜索用户失败: ${error.message}`) - } - } - - // 批量删除用户 - async deleteUsers(userIds) { - try { - if (!Array.isArray(userIds) || userIds.length === 0) { - throw new CommonError("用户ID列表不能为空") - } - - const results = [] - const errors = [] - - for (const id of userIds) { - try { - await this.deleteUser(id) - results.push(id) - } catch (error) { - errors.push({ - id, - error: error.message - }) - } - } - - return { - success: results, - errors, - total: userIds.length, - successCount: results.length, - errorCount: errors.length - } - } catch (error) { - if (error instanceof CommonError) throw error - throw new CommonError(`批量删除用户失败: ${error.message}`) - } - } -} - -export default UserService diff --git a/src/shared/utils/BaseSingleton.js b/src/shared/utils/BaseSingleton.js new file mode 100644 index 0000000..9705647 --- /dev/null +++ b/src/shared/utils/BaseSingleton.js @@ -0,0 +1,37 @@ +// 抽象基类,使用泛型来正确推导子类类型 +class BaseSingleton { + static _instance + + constructor() { + if (this.constructor === BaseSingleton) { + throw new Error("禁止直接实例化 BaseOne 抽象类") + } + + if (this.constructor._instance) { + throw new Error("构造函数私有化失败,禁止重复 new") + } + + // this.constructor 是子类,所以这里设为 instance + this.constructor._instance = this + } + + static getInstance() { + const clazz = this + if (!clazz._instance) { + const self = new this() + const handler = { + get: function (target, prop) { + const value = Reflect.get(target, prop) + if (typeof value === "function") { + return value.bind(target) + } + return Reflect.get(target, prop) + }, + } + clazz._instance = new Proxy(self, handler) + } + return clazz._instance + } +} + +export { BaseSingleton } diff --git a/src/shared/utils/ForRegister.js b/src/shared/utils/ForRegister.js new file mode 100644 index 0000000..635d30c --- /dev/null +++ b/src/shared/utils/ForRegister.js @@ -0,0 +1,117 @@ +// 自动扫描 controllers 目录并注册路由 +// 兼容传统 routes 方式和自动注册 controller 方式 +import fs from "fs" +import path from "path" +import { logger } from "../../app/bootstrap/logger.js" + +// 保证不会被摇树(tree-shaking),即使在生产环境也会被打包 +if (import.meta.env.PROD) { + // 通过引用返回值,防止被摇树优化 + let controllers = import.meta.glob("../controllers/**/*Controller.js", { eager: true }) + controllers = null + console.log(controllers); +} + +/** + * 自动扫描 controllers 目录,注册所有导出的路由 + * 自动检测 routes 目录下已手动注册的 controller,避免重复注册 + * @param {Koa} app - Koa 实例 + * @param {string} controllersDir - controllers 目录路径 + * @param {string} prefix - 路由前缀 + * @param {Set} [manualControllers] - 可选,手动传入已注册 controller 文件名集合,优先于自动扫描 + */ +export async function autoRegisterControllers(app, controllersDir) { + let allRouter = [] + + async function scan(dir, routePrefix = "") { + try { + for (const file of fs.readdirSync(dir)) { + const fullPath = path.join(dir, file) + const stat = fs.statSync(fullPath) + + if (stat.isDirectory()) { + if (!file.startsWith("_")) { + await scan(fullPath, routePrefix + "/" + file) + } + } else if (file.endsWith("Controller.js") && !file.startsWith("_")) { + try { + // 使用动态import方式,确保ES模块兼容性 + const controllerModule = await import(`file://${fullPath}`) + const controller = controllerModule.default || controllerModule + + if (!controller) { + logger.warn(`[控制器注册] ${file} - 缺少默认导出,跳过注册`) + continue + } + + const routes = controller.createRoutes || controller.default?.createRoutes || controller.default || controller + + if (typeof routes === "function") { + try { + const router = routes() + if (router && typeof router.middleware === "function") { + allRouter.push(router) + logger.info(`[控制器注册] ✅ ${file} - 路由创建成功`) + } else { + logger.warn(`[控制器注册] ⚠️ ${file} - createRoutes() 返回的不是有效的路由器对象`) + } + } catch (error) { + logger.error(`[控制器注册] ❌ ${file} - createRoutes() 执行失败: ${error.message}`) + } + } else { + logger.warn(`[控制器注册] ⚠️ ${file} - 未找到 createRoutes 方法或导出对象`) + } + } catch (importError) { + logger.error(`[控制器注册] ❌ ${file} - 模块导入失败: ${importError.message}`) + } + } + } + } catch (error) { + logger.error(`[控制器注册] ❌ 扫描目录失败 ${dir}: ${error.message}`) + } + } + + try { + await scan(controllersDir) + + if (allRouter.length === 0) { + logger.warn("[路由注册] ⚠️ 未发现任何可注册的控制器") + return + } + + logger.info(`[路由注册] 📋 发现 ${allRouter.length} 个控制器,开始注册到应用`) + + // 按顺序注册路由,确保中间件执行顺序 + for (let i = 0; i < allRouter.length; i++) { + const router = allRouter[i] + try { + app.use(router.middleware()) + logger.debug(`[路由注册] 🔗 路由 ${i + 1}/${allRouter.length} 注册成功`) + + // 枚举并紧凑输出该路由器下的所有路由方法与路径(单行聚合) + const methodEntries = Object.entries(router.routes || {}) + const items = [] + for (const [method, list] of methodEntries) { + if (!Array.isArray(list) || list.length === 0) continue + for (const r of list) { + if (!r || !r.path) continue + items.push(`${method.toUpperCase()} ${r.path}`) + } + } + if (items.length > 0) { + const prefix = router.options && router.options.prefix ? router.options.prefix : "" + logger.info(`[路由注册] ✅ ${prefix || "/"} 共 ${items.length} 条 -> ${items.join("; ")}`) + } else { + logger.warn(`[路由注册] ⚠️ 该控制器未包含任何可用路由`) + } + } catch (error) { + logger.error(`[路由注册] ❌ 路由 ${i + 1}/${allRouter.length} 注册失败: ${error.message}`) + } + } + + logger.info(`[路由注册] ✅ 完成!成功注册 ${allRouter.length} 个控制器路由`) + + } catch (error) { + logger.error(`[路由注册] ❌ 自动注册过程中发生严重错误: ${error.message}`) + } +} diff --git a/src/shared/utils/bcrypt.js b/src/shared/utils/bcrypt.js new file mode 100644 index 0000000..4c26d52 --- /dev/null +++ b/src/shared/utils/bcrypt.js @@ -0,0 +1,11 @@ +// 密码加密与校验工具 +import bcrypt from "bcryptjs" + +export async function hashPassword(password) { + const salt = await bcrypt.genSalt(10) + return bcrypt.hash(password, salt) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} diff --git a/src/shared/utils/envValidator.js b/src/shared/utils/envValidator.js new file mode 100644 index 0000000..590c829 --- /dev/null +++ b/src/shared/utils/envValidator.js @@ -0,0 +1,165 @@ +import { logger } from "../../app/bootstrap/logger.js" + +/** + * 环境变量验证配置 + * required: 必需的环境变量 + * optional: 可选的环境变量(提供默认值) + */ +const ENV_CONFIG = { + required: [ + "SESSION_SECRET", + "JWT_SECRET" + ], + optional: { + "NODE_ENV": "development", + "PORT": "3000", + "LOG_DIR": "logs", + "HTTPS_ENABLE": "off" + } +} + +/** + * 验证必需的环境变量 + * @returns {Object} 验证结果 + */ +function validateRequiredEnv() { + const missing = [] + const valid = {} + + for (const key of ENV_CONFIG.required) { + const value = process.env[key] + if (!value || value.trim() === '') { + missing.push(key) + } else { + valid[key] = value + } + } + + return { missing, valid } +} + +/** + * 设置可选环境变量的默认值 + * @returns {Object} 设置的默认值 + */ +function setOptionalDefaults() { + const defaults = {} + + for (const [key, defaultValue] of Object.entries(ENV_CONFIG.optional)) { + if (!process.env[key]) { + process.env[key] = defaultValue + defaults[key] = defaultValue + } + } + + return defaults +} + +/** + * 验证环境变量的格式和有效性 + * @param {Object} env 环境变量对象 + * @returns {Array} 错误列表 + */ +function validateEnvFormat(env) { + const errors = [] + + // 验证 PORT 是数字 + if (env.PORT && isNaN(parseInt(env.PORT))) { + errors.push("PORT must be a valid number") + } + + // 验证 NODE_ENV 的值 + const validNodeEnvs = ['development', 'production', 'test'] + if (env.NODE_ENV && !validNodeEnvs.includes(env.NODE_ENV)) { + errors.push(`NODE_ENV must be one of: ${validNodeEnvs.join(', ')}`) + } + + // 验证 SESSION_SECRET 至少包含一个密钥 + if (env.SESSION_SECRET) { + const secrets = env.SESSION_SECRET.split(',').filter(s => s.trim()) + if (secrets.length === 0) { + errors.push("SESSION_SECRET must contain at least one non-empty secret") + } + } + + // 验证 JWT_SECRET 长度 + if (env.JWT_SECRET && env.JWT_SECRET.length < 32) { + errors.push("JWT_SECRET must be at least 32 characters long for security") + } + + return errors +} + +/** + * 初始化和验证所有环境变量 + * @returns {boolean} 验证是否成功 + */ +export function validateEnvironment() { + logger.info("🔍 开始验证环境变量...") + + // 1. 验证必需的环境变量 + const { missing, valid } = validateRequiredEnv() + + if (missing.length > 0) { + logger.error("❌ 缺少必需的环境变量:") + missing.forEach(key => { + logger.error(` - ${key}`) + }) + logger.error("请设置这些环境变量后重新启动应用") + return false + } + + // 2. 设置可选环境变量的默认值 + const defaults = setOptionalDefaults() + if (Object.keys(defaults).length > 0) { + logger.info("⚙️ 设置默认环境变量:") + Object.entries(defaults).forEach(([key, value]) => { + logger.info(` - ${key}=${value}`) + }) + } + + // 3. 验证环境变量格式 + const formatErrors = validateEnvFormat(process.env) + if (formatErrors.length > 0) { + logger.error("❌ 环境变量格式错误:") + formatErrors.forEach(error => { + logger.error(` - ${error}`) + }) + return false + } + + // 4. 记录有效的环境变量(敏感信息脱敏) + logger.info("✅ 环境变量验证成功:") + logger.info(` - NODE_ENV=${process.env.NODE_ENV}`) + logger.info(` - PORT=${process.env.PORT}`) + logger.info(` - LOG_DIR=${process.env.LOG_DIR}`) + logger.info(` - SESSION_SECRET=${maskSecret(process.env.SESSION_SECRET)}`) + logger.info(` - JWT_SECRET=${maskSecret(process.env.JWT_SECRET)}`) + + return true +} + +/** + * 脱敏显示敏感信息 + * @param {string} secret 敏感字符串 + * @returns {string} 脱敏后的字符串 + */ +export function maskSecret(secret) { + if (!secret) return "未设置" + if (secret.length <= 8) return "*".repeat(secret.length) + return secret.substring(0, 4) + "*".repeat(secret.length - 8) + secret.substring(secret.length - 4) +} + +/** + * 获取环境变量配置(用于生成 .env.example) + * @returns {Object} 环境变量配置 + */ +export function getEnvConfig() { + return ENV_CONFIG +} + +export default { + validateEnvironment, + getEnvConfig, + maskSecret +} \ No newline at end of file diff --git a/src/shared/utils/error/CommonError.js b/src/shared/utils/error/CommonError.js new file mode 100644 index 0000000..a7c1995 --- /dev/null +++ b/src/shared/utils/error/CommonError.js @@ -0,0 +1,7 @@ +export default class CommonError extends Error { + constructor(message, redirect) { + super(message) + this.name = "CommonError" + this.status = 500 + } +} diff --git a/src/shared/utils/helper.js b/src/shared/utils/helper.js new file mode 100644 index 0000000..d3333cb --- /dev/null +++ b/src/shared/utils/helper.js @@ -0,0 +1,26 @@ +import { app } from "../../app/bootstrap/app.js" + +function ResponseSuccess(data = null, message = null) { + return { success: true, error: message, data } +} + +function ResponseError(data = null, message = null) { + return { success: false, error: message, data } +} + +function ResponseJSON(statusCode = 200, data = null, message = null) { + app.currentContext.status = statusCode + return (app.currentContext.body = { success: true, error: message, data }) +} + +const R = { + ResponseSuccess, + ResponseError, + ResponseJSON, +} + +R.SUCCESS = 200 +R.ERROR = 500 +R.NOTFOUND = 404 + +export { R } diff --git a/src/shared/utils/router.js b/src/shared/utils/router.js new file mode 100644 index 0000000..e6c5a06 --- /dev/null +++ b/src/shared/utils/router.js @@ -0,0 +1,139 @@ +import { match } from 'path-to-regexp'; +import compose from 'koa-compose'; +import RouteAuth from './router/RouteAuth.js'; + +class Router { + /** + * 初始化路由实例 + * @param {Object} options - 路由配置 + * @param {string} options.prefix - 全局路由前缀 + * @param {Object} options.auth - 全局默认auth配置(可选,优先级低于路由级) + */ + constructor(options = {}) { + this.routes = { get: [], post: [], put: [], delete: [] }; + this.middlewares = []; + this.options = Object.assign({}, this.options, options); + } + + options = { + prefix: '', + auth: true, + } + + /** + * 注册中间件 + * @param {Function} middleware - 中间件函数 + */ + use(middleware) { + this.middlewares.push(middleware); + } + + /** + * 注册GET路由,支持中间件链 + * @param {string} path - 路由路径 + * @param {Function} handler - 中间件和处理函数 + * @param {Object} others - 其他参数(可选) + */ + get(path, handler, others) { + this._registerRoute("get", path, handler, others) + } + + /** + * 注册POST路由,支持中间件链 + * @param {string} path - 路由路径 + * @param {Function} handler - 中间件和处理函数 + * @param {Object} others - 其他参数(可选) + */ + post(path, handler, others) { + this._registerRoute("post", path, handler, others) + } + + /** + * 注册PUT路由,支持中间件链 + */ + put(path, handler, others) { + this._registerRoute("put", path, handler, others) + } + + /** + * 注册DELETE路由,支持中间件链 + */ + delete(path, handler, others) { + this._registerRoute("delete", path, handler, others) + } + + /** + * 创建路由组 + * @param {string} prefix - 组内路由前缀 + * @param {Function} callback - 组路由注册回调 + */ + group(prefix, callback) { + const groupRouter = new Router({ prefix: this.options.prefix + prefix }) + callback(groupRouter); + // 合并组路由到当前路由 + Object.keys(groupRouter.routes).forEach(method => { + this.routes[method].push(...groupRouter.routes[method]); + }); + this.middlewares.push(...groupRouter.middlewares); + } + + /** + * 生成Koa中间件 + * @returns {Function} Koa中间件函数 + */ + middleware() { + return async (ctx, next) => { + const { method, path } = ctx; + const route = this._matchRoute(method.toLowerCase(), path); + + // 组合全局中间件、路由专属中间件和 handler + const middlewares = [...this.middlewares]; + if (route) { + // 如果匹配到路由,添加路由专属中间件和处理函数 + ctx.params = route.params; + + let isAuth = this.options.auth; + if (route.meta && route.meta.auth !== undefined) { + isAuth = route.meta.auth; + } + + middlewares.push(RouteAuth({ auth: isAuth })); + middlewares.push(route.handler) + // 用 koa-compose 组合 + const composed = compose(middlewares); + await composed(ctx, next); + } else { + // 如果没有匹配到路由,直接调用 next + await next(); + } + }; + } + + /** + * 内部路由注册方法,支持中间件链 + * @private + */ + _registerRoute(method, path, handler, others) { + const fullPath = this.options.prefix + path + const keys = []; + const matcher = match(fullPath, { decode: decodeURIComponent }); + this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others }) + } + + /** + * 匹配路由 + * @private + */ + _matchRoute(method, currentPath) { + const routes = this.routes[method] || []; + for (const route of routes) { + const matchResult = route.matcher(currentPath); + if (matchResult) { + return { ...route, params: matchResult.params }; + } + } + return null; + } +} + +export default Router; \ No newline at end of file diff --git a/src/shared/utils/router/RouteAuth.js b/src/shared/utils/router/RouteAuth.js new file mode 100644 index 0000000..bbe6f4c --- /dev/null +++ b/src/shared/utils/router/RouteAuth.js @@ -0,0 +1,49 @@ +import jwt from "../../../presentation/middlewares/Auth/jwt.js" +import { JWT_SECRET } from "../../../presentation/middlewares/Auth/auth.js" + +/** + * 路由级权限中间件 + * 支持:auth: false/try/true/roles + * 用法:router.get('/api/user', RouteAuth({ auth: true }), handler) + */ +export default function RouteAuth(options = {}) { + const { auth = true } = options + return async (ctx, next) => { + if (auth === false) return next() + + // 统一用户解析逻辑 + if (!ctx.state.user) { + const token = getToken(ctx) + if (token) { + try { + ctx.state.user = jwt.verify(token, JWT_SECRET) + } catch {} + } + } + + if (auth === "try") { + return next() + } + + if (auth === true) { + if (!ctx.state.user) { + if (ctx.accepts('html')) { + ctx.redirect('/no-auth?from=' + ctx.request.url) + return + } + ctx.status = 401 + ctx.body = { success: false, error: "未登录或Token无效" } + return + } + return next() + } + + // 其他自定义模式 + return next() + } +} + +function getToken(ctx) { + // 只支持 Authorization: Bearer xxx + return ctx.headers["authorization"]?.replace(/^Bearer\s/i, "") +} diff --git a/src/shared/utils/scheduler.js b/src/shared/utils/scheduler.js new file mode 100644 index 0000000..27ea36f --- /dev/null +++ b/src/shared/utils/scheduler.js @@ -0,0 +1,60 @@ +import cron from 'node-cron'; + +class Scheduler { + constructor() { + this.jobs = new Map(); + } + + add(id, cronTime, task, options = {}) { + if (this.jobs.has(id)) this.remove(id); + const job = cron.createTask(cronTime, task, { ...options, noOverlap: true }); + this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); + } + + execute(id) { + const entry = this.jobs.get(id); + if (entry && entry.status === 'running') { + entry.job.execute(); + } + } + + start(id) { + const entry = this.jobs.get(id); + if (entry && entry.status !== 'running') { + entry.job.start(); + entry.status = 'running'; + } + } + + stop(id) { + const entry = this.jobs.get(id); + if (entry && entry.status === 'running') { + entry.job.stop(); + entry.status = 'stopped'; + } + } + + remove(id) { + const entry = this.jobs.get(id); + if (entry) { + entry.job.destroy(); + this.jobs.delete(id); + } + } + + updateCronTime(id, newCronTime) { + const entry = this.jobs.get(id); + if (entry) { + this.remove(id); + this.add(id, newCronTime, entry.task, entry.options); + } + } + + list() { + return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({ + id, cronTime, status + })); + } +} + +export default new Scheduler(); diff --git a/src/utils/BaseSingleton.js b/src/utils/BaseSingleton.js deleted file mode 100644 index 9705647..0000000 --- a/src/utils/BaseSingleton.js +++ /dev/null @@ -1,37 +0,0 @@ -// 抽象基类,使用泛型来正确推导子类类型 -class BaseSingleton { - static _instance - - constructor() { - if (this.constructor === BaseSingleton) { - throw new Error("禁止直接实例化 BaseOne 抽象类") - } - - if (this.constructor._instance) { - throw new Error("构造函数私有化失败,禁止重复 new") - } - - // this.constructor 是子类,所以这里设为 instance - this.constructor._instance = this - } - - static getInstance() { - const clazz = this - if (!clazz._instance) { - const self = new this() - const handler = { - get: function (target, prop) { - const value = Reflect.get(target, prop) - if (typeof value === "function") { - return value.bind(target) - } - return Reflect.get(target, prop) - }, - } - clazz._instance = new Proxy(self, handler) - } - return clazz._instance - } -} - -export { BaseSingleton } diff --git a/src/utils/ForRegister.js b/src/utils/ForRegister.js deleted file mode 100644 index 39b1b70..0000000 --- a/src/utils/ForRegister.js +++ /dev/null @@ -1,117 +0,0 @@ -// 自动扫描 controllers 目录并注册路由 -// 兼容传统 routes 方式和自动注册 controller 方式 -import fs from "fs" -import path from "path" -import { logger } from "@/logger.js" - -// 保证不会被摇树(tree-shaking),即使在生产环境也会被打包 -if (import.meta.env.PROD) { - // 通过引用返回值,防止被摇树优化 - let controllers = import.meta.glob("../controllers/**/*Controller.js", { eager: true }) - controllers = null - console.log(controllers); -} - -/** - * 自动扫描 controllers 目录,注册所有导出的路由 - * 自动检测 routes 目录下已手动注册的 controller,避免重复注册 - * @param {Koa} app - Koa 实例 - * @param {string} controllersDir - controllers 目录路径 - * @param {string} prefix - 路由前缀 - * @param {Set} [manualControllers] - 可选,手动传入已注册 controller 文件名集合,优先于自动扫描 - */ -export function autoRegisterControllers(app, controllersDir) { - let allRouter = [] - - function scan(dir, routePrefix = "") { - try { - for (const file of fs.readdirSync(dir)) { - const fullPath = path.join(dir, file) - const stat = fs.statSync(fullPath) - - if (stat.isDirectory()) { - if (!file.startsWith("_")) { - scan(fullPath, routePrefix + "/" + file) - } - } else if (file.endsWith("Controller.js") && !file.startsWith("_")) { - try { - // 使用同步的import方式,确保ES模块兼容性 - const controllerModule = require(fullPath) - const controller = controllerModule.default || controllerModule - - if (!controller) { - logger.warn(`[控制器注册] ${file} - 缺少默认导出,跳过注册`) - continue - } - - const routes = controller.createRoutes || controller.default?.createRoutes || controller.default || controller - - if (typeof routes === "function") { - try { - const router = routes() - if (router && typeof router.middleware === "function") { - allRouter.push(router) - logger.info(`[控制器注册] ✅ ${file} - 路由创建成功`) - } else { - logger.warn(`[控制器注册] ⚠️ ${file} - createRoutes() 返回的不是有效的路由器对象`) - } - } catch (error) { - logger.error(`[控制器注册] ❌ ${file} - createRoutes() 执行失败: ${error.message}`) - } - } else { - logger.warn(`[控制器注册] ⚠️ ${file} - 未找到 createRoutes 方法或导出对象`) - } - } catch (importError) { - logger.error(`[控制器注册] ❌ ${file} - 模块导入失败: ${importError.message}`) - } - } - } - } catch (error) { - logger.error(`[控制器注册] ❌ 扫描目录失败 ${dir}: ${error.message}`) - } - } - - try { - scan(controllersDir) - - if (allRouter.length === 0) { - logger.warn("[路由注册] ⚠️ 未发现任何可注册的控制器") - return - } - - logger.info(`[路由注册] 📋 发现 ${allRouter.length} 个控制器,开始注册到应用`) - - // 按顺序注册路由,确保中间件执行顺序 - for (let i = 0; i < allRouter.length; i++) { - const router = allRouter[i] - try { - app.use(router.middleware()) - logger.debug(`[路由注册] 🔗 路由 ${i + 1}/${allRouter.length} 注册成功`) - - // 枚举并紧凑输出该路由器下的所有路由方法与路径(单行聚合) - const methodEntries = Object.entries(router.routes || {}) - const items = [] - for (const [method, list] of methodEntries) { - if (!Array.isArray(list) || list.length === 0) continue - for (const r of list) { - if (!r || !r.path) continue - items.push(`${method.toUpperCase()} ${r.path}`) - } - } - if (items.length > 0) { - const prefix = router.options && router.options.prefix ? router.options.prefix : "" - logger.info(`[路由注册] ✅ ${prefix || "/"} 共 ${items.length} 条 -> ${items.join("; ")}`) - } else { - logger.warn(`[路由注册] ⚠️ 该控制器未包含任何可用路由`) - } - } catch (error) { - logger.error(`[路由注册] ❌ 路由 ${i + 1}/${allRouter.length} 注册失败: ${error.message}`) - } - } - - logger.info(`[路由注册] ✅ 完成!成功注册 ${allRouter.length} 个控制器路由`) - - } catch (error) { - logger.error(`[路由注册] ❌ 自动注册过程中发生严重错误: ${error.message}`) - } -} diff --git a/src/utils/bcrypt.js b/src/utils/bcrypt.js deleted file mode 100644 index 4c26d52..0000000 --- a/src/utils/bcrypt.js +++ /dev/null @@ -1,11 +0,0 @@ -// 密码加密与校验工具 -import bcrypt from "bcryptjs" - -export async function hashPassword(password) { - const salt = await bcrypt.genSalt(10) - return bcrypt.hash(password, salt) -} - -export async function comparePassword(password, hash) { - return bcrypt.compare(password, hash) -} diff --git a/src/utils/envValidator.js b/src/utils/envValidator.js deleted file mode 100644 index fc9fb03..0000000 --- a/src/utils/envValidator.js +++ /dev/null @@ -1,165 +0,0 @@ -import { logger } from "@/logger.js" - -/** - * 环境变量验证配置 - * required: 必需的环境变量 - * optional: 可选的环境变量(提供默认值) - */ -const ENV_CONFIG = { - required: [ - "SESSION_SECRET", - "JWT_SECRET" - ], - optional: { - "NODE_ENV": "development", - "PORT": "3000", - "LOG_DIR": "logs", - "HTTPS_ENABLE": "off" - } -} - -/** - * 验证必需的环境变量 - * @returns {Object} 验证结果 - */ -function validateRequiredEnv() { - const missing = [] - const valid = {} - - for (const key of ENV_CONFIG.required) { - const value = process.env[key] - if (!value || value.trim() === '') { - missing.push(key) - } else { - valid[key] = value - } - } - - return { missing, valid } -} - -/** - * 设置可选环境变量的默认值 - * @returns {Object} 设置的默认值 - */ -function setOptionalDefaults() { - const defaults = {} - - for (const [key, defaultValue] of Object.entries(ENV_CONFIG.optional)) { - if (!process.env[key]) { - process.env[key] = defaultValue - defaults[key] = defaultValue - } - } - - return defaults -} - -/** - * 验证环境变量的格式和有效性 - * @param {Object} env 环境变量对象 - * @returns {Array} 错误列表 - */ -function validateEnvFormat(env) { - const errors = [] - - // 验证 PORT 是数字 - if (env.PORT && isNaN(parseInt(env.PORT))) { - errors.push("PORT must be a valid number") - } - - // 验证 NODE_ENV 的值 - const validNodeEnvs = ['development', 'production', 'test'] - if (env.NODE_ENV && !validNodeEnvs.includes(env.NODE_ENV)) { - errors.push(`NODE_ENV must be one of: ${validNodeEnvs.join(', ')}`) - } - - // 验证 SESSION_SECRET 至少包含一个密钥 - if (env.SESSION_SECRET) { - const secrets = env.SESSION_SECRET.split(',').filter(s => s.trim()) - if (secrets.length === 0) { - errors.push("SESSION_SECRET must contain at least one non-empty secret") - } - } - - // 验证 JWT_SECRET 长度 - if (env.JWT_SECRET && env.JWT_SECRET.length < 32) { - errors.push("JWT_SECRET must be at least 32 characters long for security") - } - - return errors -} - -/** - * 初始化和验证所有环境变量 - * @returns {boolean} 验证是否成功 - */ -export function validateEnvironment() { - logger.info("🔍 开始验证环境变量...") - - // 1. 验证必需的环境变量 - const { missing, valid } = validateRequiredEnv() - - if (missing.length > 0) { - logger.error("❌ 缺少必需的环境变量:") - missing.forEach(key => { - logger.error(` - ${key}`) - }) - logger.error("请设置这些环境变量后重新启动应用") - return false - } - - // 2. 设置可选环境变量的默认值 - const defaults = setOptionalDefaults() - if (Object.keys(defaults).length > 0) { - logger.info("⚙️ 设置默认环境变量:") - Object.entries(defaults).forEach(([key, value]) => { - logger.info(` - ${key}=${value}`) - }) - } - - // 3. 验证环境变量格式 - const formatErrors = validateEnvFormat(process.env) - if (formatErrors.length > 0) { - logger.error("❌ 环境变量格式错误:") - formatErrors.forEach(error => { - logger.error(` - ${error}`) - }) - return false - } - - // 4. 记录有效的环境变量(敏感信息脱敏) - logger.info("✅ 环境变量验证成功:") - logger.info(` - NODE_ENV=${process.env.NODE_ENV}`) - logger.info(` - PORT=${process.env.PORT}`) - logger.info(` - LOG_DIR=${process.env.LOG_DIR}`) - logger.info(` - SESSION_SECRET=${maskSecret(process.env.SESSION_SECRET)}`) - logger.info(` - JWT_SECRET=${maskSecret(process.env.JWT_SECRET)}`) - - return true -} - -/** - * 脱敏显示敏感信息 - * @param {string} secret 敏感字符串 - * @returns {string} 脱敏后的字符串 - */ -export function maskSecret(secret) { - if (!secret) return "未设置" - if (secret.length <= 8) return "*".repeat(secret.length) - return secret.substring(0, 4) + "*".repeat(secret.length - 8) + secret.substring(secret.length - 4) -} - -/** - * 获取环境变量配置(用于生成 .env.example) - * @returns {Object} 环境变量配置 - */ -export function getEnvConfig() { - return ENV_CONFIG -} - -export default { - validateEnvironment, - getEnvConfig, - maskSecret -} \ No newline at end of file diff --git a/src/utils/error/CommonError.js b/src/utils/error/CommonError.js deleted file mode 100644 index a7c1995..0000000 --- a/src/utils/error/CommonError.js +++ /dev/null @@ -1,7 +0,0 @@ -export default class CommonError extends Error { - constructor(message, redirect) { - super(message) - this.name = "CommonError" - this.status = 500 - } -} diff --git a/src/utils/helper.js b/src/utils/helper.js deleted file mode 100644 index ffa829b..0000000 --- a/src/utils/helper.js +++ /dev/null @@ -1,26 +0,0 @@ -import { app } from "@/global" - -function ResponseSuccess(data = null, message = null) { - return { success: true, error: message, data } -} - -function ResponseError(data = null, message = null) { - return { success: false, error: message, data } -} - -function ResponseJSON(statusCode = 200, data = null, message = null) { - app.currentContext.status = statusCode - return (app.currentContext.body = { success: true, error: message, data }) -} - -const R = { - ResponseSuccess, - ResponseError, - ResponseJSON, -} - -R.SUCCESS = 200 -R.ERROR = 500 -R.NOTFOUND = 404 - -export { R } diff --git a/src/utils/router.js b/src/utils/router.js deleted file mode 100644 index e6c5a06..0000000 --- a/src/utils/router.js +++ /dev/null @@ -1,139 +0,0 @@ -import { match } from 'path-to-regexp'; -import compose from 'koa-compose'; -import RouteAuth from './router/RouteAuth.js'; - -class Router { - /** - * 初始化路由实例 - * @param {Object} options - 路由配置 - * @param {string} options.prefix - 全局路由前缀 - * @param {Object} options.auth - 全局默认auth配置(可选,优先级低于路由级) - */ - constructor(options = {}) { - this.routes = { get: [], post: [], put: [], delete: [] }; - this.middlewares = []; - this.options = Object.assign({}, this.options, options); - } - - options = { - prefix: '', - auth: true, - } - - /** - * 注册中间件 - * @param {Function} middleware - 中间件函数 - */ - use(middleware) { - this.middlewares.push(middleware); - } - - /** - * 注册GET路由,支持中间件链 - * @param {string} path - 路由路径 - * @param {Function} handler - 中间件和处理函数 - * @param {Object} others - 其他参数(可选) - */ - get(path, handler, others) { - this._registerRoute("get", path, handler, others) - } - - /** - * 注册POST路由,支持中间件链 - * @param {string} path - 路由路径 - * @param {Function} handler - 中间件和处理函数 - * @param {Object} others - 其他参数(可选) - */ - post(path, handler, others) { - this._registerRoute("post", path, handler, others) - } - - /** - * 注册PUT路由,支持中间件链 - */ - put(path, handler, others) { - this._registerRoute("put", path, handler, others) - } - - /** - * 注册DELETE路由,支持中间件链 - */ - delete(path, handler, others) { - this._registerRoute("delete", path, handler, others) - } - - /** - * 创建路由组 - * @param {string} prefix - 组内路由前缀 - * @param {Function} callback - 组路由注册回调 - */ - group(prefix, callback) { - const groupRouter = new Router({ prefix: this.options.prefix + prefix }) - callback(groupRouter); - // 合并组路由到当前路由 - Object.keys(groupRouter.routes).forEach(method => { - this.routes[method].push(...groupRouter.routes[method]); - }); - this.middlewares.push(...groupRouter.middlewares); - } - - /** - * 生成Koa中间件 - * @returns {Function} Koa中间件函数 - */ - middleware() { - return async (ctx, next) => { - const { method, path } = ctx; - const route = this._matchRoute(method.toLowerCase(), path); - - // 组合全局中间件、路由专属中间件和 handler - const middlewares = [...this.middlewares]; - if (route) { - // 如果匹配到路由,添加路由专属中间件和处理函数 - ctx.params = route.params; - - let isAuth = this.options.auth; - if (route.meta && route.meta.auth !== undefined) { - isAuth = route.meta.auth; - } - - middlewares.push(RouteAuth({ auth: isAuth })); - middlewares.push(route.handler) - // 用 koa-compose 组合 - const composed = compose(middlewares); - await composed(ctx, next); - } else { - // 如果没有匹配到路由,直接调用 next - await next(); - } - }; - } - - /** - * 内部路由注册方法,支持中间件链 - * @private - */ - _registerRoute(method, path, handler, others) { - const fullPath = this.options.prefix + path - const keys = []; - const matcher = match(fullPath, { decode: decodeURIComponent }); - this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others }) - } - - /** - * 匹配路由 - * @private - */ - _matchRoute(method, currentPath) { - const routes = this.routes[method] || []; - for (const route of routes) { - const matchResult = route.matcher(currentPath); - if (matchResult) { - return { ...route, params: matchResult.params }; - } - } - return null; - } -} - -export default Router; \ No newline at end of file diff --git a/src/utils/router/RouteAuth.js b/src/utils/router/RouteAuth.js deleted file mode 100644 index d1a4e83..0000000 --- a/src/utils/router/RouteAuth.js +++ /dev/null @@ -1,49 +0,0 @@ -import jwt from "@/middlewares/Auth/jwt.js" -import { JWT_SECRET } from "@/middlewares/Auth/auth.js" - -/** - * 路由级权限中间件 - * 支持:auth: false/try/true/roles - * 用法:router.get('/api/user', RouteAuth({ auth: true }), handler) - */ -export default function RouteAuth(options = {}) { - const { auth = true } = options - return async (ctx, next) => { - if (auth === false) return next() - - // 统一用户解析逻辑 - if (!ctx.state.user) { - const token = getToken(ctx) - if (token) { - try { - ctx.state.user = jwt.verify(token, JWT_SECRET) - } catch {} - } - } - - if (auth === "try") { - return next() - } - - if (auth === true) { - if (!ctx.state.user) { - if (ctx.accepts('html')) { - ctx.redirect('/no-auth?from=' + ctx.request.url) - return - } - ctx.status = 401 - ctx.body = { success: false, error: "未登录或Token无效" } - return - } - return next() - } - - // 其他自定义模式 - return next() - } -} - -function getToken(ctx) { - // 只支持 Authorization: Bearer xxx - return ctx.headers["authorization"]?.replace(/^Bearer\s/i, "") -} diff --git a/src/utils/scheduler.js b/src/utils/scheduler.js deleted file mode 100644 index 27ea36f..0000000 --- a/src/utils/scheduler.js +++ /dev/null @@ -1,60 +0,0 @@ -import cron from 'node-cron'; - -class Scheduler { - constructor() { - this.jobs = new Map(); - } - - add(id, cronTime, task, options = {}) { - if (this.jobs.has(id)) this.remove(id); - const job = cron.createTask(cronTime, task, { ...options, noOverlap: true }); - this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); - } - - execute(id) { - const entry = this.jobs.get(id); - if (entry && entry.status === 'running') { - entry.job.execute(); - } - } - - start(id) { - const entry = this.jobs.get(id); - if (entry && entry.status !== 'running') { - entry.job.start(); - entry.status = 'running'; - } - } - - stop(id) { - const entry = this.jobs.get(id); - if (entry && entry.status === 'running') { - entry.job.stop(); - entry.status = 'stopped'; - } - } - - remove(id) { - const entry = this.jobs.get(id); - if (entry) { - entry.job.destroy(); - this.jobs.delete(id); - } - } - - updateCronTime(id, newCronTime) { - const entry = this.jobs.get(id); - if (entry) { - this.remove(id); - this.add(id, newCronTime, entry.task, entry.options); - } - } - - list() { - return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({ - id, cronTime, status - })); - } -} - -export default new Scheduler(); diff --git a/src/views/error/index.pug b/src/views/error/index.pug deleted file mode 100644 index 5d39c06..0000000 --- a/src/views/error/index.pug +++ /dev/null @@ -1,8 +0,0 @@ -html - head - title #{status} Error - body - h1 #{status} Error - p #{message} - if isDev && stack - pre(style="color:red;") #{stack} \ No newline at end of file diff --git a/src/views/htmx/footer.pug b/src/views/htmx/footer.pug deleted file mode 100644 index 42f27b3..0000000 --- a/src/views/htmx/footer.pug +++ /dev/null @@ -1,53 +0,0 @@ -.footer-panel - .footer-content - p.back-to-top © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 - - ul.footer-links - li - a(href="/") 首页 - li - a(href="/about") 关于我们 - li - a(href="/contact") 联系我们 - style. - .footer-panel { - background: rgba(34,34,34,.25); - backdrop-filter: blur(12px); - color: #eee; - padding: 24px 0 24px 0; - font-size: 15px; - margin-top: 40px; - min-height: 100px; - display: flex; - align-items: center; - justify-content: center; - } - .footer-content { - max-width: 900px; - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - } - .footer-content p { - margin: 0 0 10px 0; - letter-spacing: 1px; - } - .footer-links { - list-style: none; - padding: 0; - display: flex; - gap: 24px; - } - .footer-links li { - display: inline; - } - .footer-links a { - color: #eee; - text-decoration: none; - transition: color 0.2s; - } - .footer-links a:hover { - color: #4fc3f7; - text-decoration: underline; - } \ No newline at end of file diff --git a/src/views/htmx/login.pug b/src/views/htmx/login.pug deleted file mode 100644 index 510ec17..0000000 --- a/src/views/htmx/login.pug +++ /dev/null @@ -1,13 +0,0 @@ -if edit - .row.justify-content-center.mt-5 - .col-md-6 - form#loginForm(method="post" action="/api/login" hx-post="/api/login" hx-trigger="submit" hx-target="body" hx-swap="none" hx-on:htmx:afterRequest="if(event.detail.xhr.status===200){window.location='/';}") - .mb-3 - label.form-label(for="username") 用户名 - input.form-control(type="text" id="username" name="username" required) - .mb-3 - label.form-label(for="password") 密码 - input.form-control(type="password" id="password" name="password" required) - button.btn.btn-primary(type="submit") 登录 -else - div sad 404 \ No newline at end of file diff --git a/src/views/htmx/navbar.pug b/src/views/htmx/navbar.pug deleted file mode 100644 index 8666b55..0000000 --- a/src/views/htmx/navbar.pug +++ /dev/null @@ -1,86 +0,0 @@ -style. - .navbar { - height: 60px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - color: #fff; - &::after { - display: table; - clear: both; - content: ''; - } - } - .navbar .site { - float: left; - height: 100%; - display: flex; - align-items: center; - padding: 0 20px; - cursor: pointer; - font-size: 20px; - &:hover { - background: rgba(255, 255, 255, 0.1); - } - } - .menu { - height: 100%; - margin-left: 20px; - .menu-item { - height: 100%; - display: flex; - align-items: center; - padding: 0 10px; - cursor: pointer; - &+.menu-item { - margin-left: 5px; - } - &:hover { - background: rgba(255, 255, 255, 0.1); - } - } - } - .menu.left { - float: left; - .menu-item { - float: left; - } - } - .right.menu { - float: right; - .menu-item { - padding: 0 20px; - float: right; - } - } -script. - window.addEventListener('pageshow', function(event) { - // event.persisted 为 true 表示页面从缓存中恢复 - if (event.persisted) { - // 执行需要更新的操作,例如: - console.log('页面从缓存加载,需要更新数据'); - - // 1. 刷新页面(简单直接的方式) - //- window.location.reload(); - - // 2. 重新请求数据(更优雅的方式) - //- fetchData(); // 假设这是你的数据请求函数 - - // 3. 更新页面状态 - //- updatePageState(); // 假设这是你的状态更新函数 - } - }); - -.navbar - .site #{$site.site_title} - .left.menu - a.menu-item(href="/about") 明月照佳人 - a.menu-item(href="/about") 岁月催人老 - if !isLogin - .right.menu - a.menu-item(href="/login") 登录 - a.menu-item(href="/register") 注册 - else - .right.menu - a.menu-item(hx-post="/logout") 退出 - a.menu-item(href="/profile") 欢迎您 , #{$user.username} \ No newline at end of file diff --git a/src/views/htmx/timeline.pug b/src/views/htmx/timeline.pug deleted file mode 100644 index 6849e9b..0000000 --- a/src/views/htmx/timeline.pug +++ /dev/null @@ -1,140 +0,0 @@ -- var _dataList = timeLine || [] -ul.time-line - each item in _dataList - li.time-line-item - .timeline-icon - div !{item.icon} - .time-line-item-content - .time-line-item-title !{item.title} - .time-line-item-desc !{item.desc} - style. - .time-line { - display: flex; - flex-direction: column; - justify-content: center; - position: relative; - } - - .time-line:before { - content: ""; - width: 3px; - height: 100%; - background: rgba(255, 255, 255, 0.37); - backdrop-filter: blur(12px); - left: 50%; - top: 0; - position: absolute; - transform: translateX(-50%); - } - - .time-line::after { - content: ""; - position: absolute; - left: 50%; - top: 100%; - width: 0; - height: 0; - border-top: 12px solid rgba(255, 255, 255, 0.37); - border-right: 7px solid transparent; - border-left: 7px solid transparent; - backdrop-filter: blur(12px); - transform: translateX(-50%); - } - - .time-line a { - color: rgb(219, 255, 121); - text-decoration: underline; - font-weight: 600; - transition: color 0.2s, background 0.2s; - border-radius: 8px; - padding: 1px 4px; - } - .time-line a:hover { - color: #fff; - background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%); - text-decoration: none; - } - - .time-line-item { - color: white; - width: 900px; - margin: 20px auto; - position: relative; - } - - .time-line-item:first-child { - margin-top: 0; - } - - .time-line-item:last-child { - margin-bottom: 50px; - } - - .timeline-icon { - position: absolute; - width: 100px; - height: 50px; - background-color: #ee4d4d7a; - backdrop-filter: blur(12px); - left: 50%; - top: 0; - transform: translateX(-50%); - display: flex; - align-items: center; - justify-content: center; - font-family: Arial, Helvetica, sans-serif; - } - - .time-line-item-title { - background-color: #ee4d4d7a; - backdrop-filter: blur(12px); - height: 50px; - line-height: 50px; - padding: 0 20px; - } - - .time-line-item:nth-child(odd) .time-line-item-content { - color: white; - width: 50%; - padding-right: 80px; - } - - .time-line-item:nth-child(odd) .time-line-item-content::before { - content: ""; - position: absolute; - left: calc(50% - 80px); - top: 20px; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-left: 7px solid #ee4d4d7a; - backdrop-filter: blur(12px); - } - - .time-line-item:nth-child(even) .time-line-item-content { - float: right; - width: 50%; - padding-left: 80px; - } - - .time-line-item:nth-child(even) .time-line-item-content::before { - content: ""; - position: absolute; - right: calc(50% - 80px); - top: 20px; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid #ee4d4d7a; - backdrop-filter: blur(12px); - } - - .time-line-item-desc { - background-color: #ffffff54; - backdrop-filter: blur(12px); - color: #fff; - padding: 20px; - line-height: 1.4; - } \ No newline at end of file diff --git a/src/views/layouts/base.pug b/src/views/layouts/base.pug deleted file mode 100644 index c8f6c3b..0000000 --- a/src/views/layouts/base.pug +++ /dev/null @@ -1,58 +0,0 @@ -mixin include() - if block - block - -mixin css(url, extranl = false) - if extranl || url.startsWith('http') || url.startsWith('//') - link(rel="stylesheet" type="text/css" href=url) - else - link(rel="stylesheet", href=($config && $config.base || "") + url) - -mixin js(url, extranl = false) - if extranl || url.startsWith('http') || url.startsWith('//') - script(type="text/javascript" src=url) - else - script(src=($config && $config.base || "") + url) - -mixin link(href, name) - //- attributes == {class: "btn"} - a(href=href)&attributes(attributes)= name - -doctype html -html(lang="zh-CN") - head - block head - title #{site_title || $site && $site.site_title || ''} - meta(name="description" content=site_description || $site && $site.site_description || '') - meta(name="keywords" content=keywords || $site && $site.keywords || '') - if $site && $site.site_favicon - link(rel="shortcut icon", href=$site.site_favicon) - meta(charset="utf-8") - meta(name="viewport" content="width=device-width, initial-scale=1") - +css('reset.css') - +js('lib/htmx.min.js') - +js('https://cdn.tailwindcss.com') - +css('https://unpkg.com/simplebar@latest/dist/simplebar.css', true) - +css('simplebar-shim.css') - +js('https://unpkg.com/simplebar@latest/dist/simplebar.min.js', true) - //- body(style="--bg:url("+($site && $site.site_bg || '#fff')+")") - //- body(style="--bg:url(./static/bg2.webp)") - body - noscript - style. - .simplebar-content-wrapper { - scrollbar-width: auto; - -ms-overflow-style: auto; - } - - .simplebar-content-wrapper::-webkit-scrollbar, - .simplebar-hide-scrollbar::-webkit-scrollbar { - display: initial; - width: initial; - height: initial; - } - div(data-simplebar style="height: 100%") - div(style="height: 100%; display: flex; flex-direction: column") - block content - block scripts - +js('lib/bg-change.js') diff --git a/src/views/layouts/bg-page.pug b/src/views/layouts/bg-page.pug deleted file mode 100644 index 48c4374..0000000 --- a/src/views/layouts/bg-page.pug +++ /dev/null @@ -1,18 +0,0 @@ -extends /layouts/root.pug -//- 采用纯背景页面的布局,背景图片随机切换,卡片采用高斯滤镜类玻璃化效果 -//- .card - -block $$head - +css('css/layouts/bg-page.css') - block pageHead - -block $$content - .page-layout - .page - block pageContent - footer - include /htmx/footer.pug - -block $$scripts - +js('lib/bg-change.js') - block pageScripts diff --git a/src/views/layouts/empty.pug b/src/views/layouts/empty.pug deleted file mode 100644 index 2a97747..0000000 --- a/src/views/layouts/empty.pug +++ /dev/null @@ -1,122 +0,0 @@ -extends /layouts/root.pug -//- 采用纯背景页面的布局,背景图片随机切换,卡片采用高斯滤镜类玻璃化效果 - -block $$head - +css('css/layouts/empty.css') - block pageHead - -block $$content - nav.navbar(class="relative") - .placeholder.mb-5(class="h-[45px] w-full opacity-0") - .fixed-container(class="shadow fixed bg-white h-[45px] top-0 left-0 right-0 z-10") - .container.clearfix(class="h-full") - .navbar-brand - a(href="/" class="text-[20px]") - #{$site.site_title} - // 桌面端菜单 - .left.menu.desktop-only - a.menu-item( - href="/articles" - class=(currentPath === '/articles' || currentPath === '/articles/' - ? 'text-blue-600 font-bold border-b-2 border-blue-600' - : 'text-gray-700 hover:text-blue-600 hover:border-b-2 hover:border-blue-400' - ) - ) 所有文章 - if !isLogin - .right.menu.desktop-only - a.menu-item(href="/login") 登录 - a.menu-item(href="/register") 注册 - else - .right.menu.desktop-only - a.menu-item(hx-post="/logout") 退出 - a.menu-item(href="/profile") 欢迎您 , #{$user.name} - a.menu-item(href="/notice") - .fe--notice-active - // 移动端:汉堡按钮 - button.menu-toggle(type="button" aria-label="打开菜单") - span.bar - span.bar - span.bar - // 移动端菜单内容(与桌面端一致) - .mobile-menu.container - .left.menu - a.menu-item(href="/articles") 所有文章 - if !isLogin - .right.menu - a.menu-item(href="/login") 登录 - a.menu-item(href="/register") 注册 - else - .right.menu - a.menu-item(hx-post="/logout") 退出 - a.menu-item() 欢迎您 , #{$user.name} - a.menu-item(href="/notice" class="fe--notice-active") 公告 - .page-layout - .page.container - block pageContent - - footer.footer.shadow.mt-5 - .footer-panel(class="bg-white border-t border-gray-200") - .footer-content.container(class="pt-12 pb-6") - .footer-main(class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8") - .footer-section - h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") #{$site.site_title} - p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 - - .footer-section - h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 快速链接 - ul.footer-links(class="space-y-3") - li - a(href="/" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 首页 - li - a(href="/about" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 关于我们 - li - a(href="/contact" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 联系我们 - li - a(href="/help" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 帮助中心 - - .footer-section - h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 服务支持 - ul.footer-links(class="space-y-3") - li - a(href="/terms" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 服务条款 - li - a(href="/privacy" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 隐私政策 - li - a(href="/faq" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 常见问题 - li - a(href="/feedback" class="text-gray-600 hover:text-blue-600 transition-colors duration-200 text-sm") 意见反馈 - - .footer-section - h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 关注我 - .social-links(class="flex space-x-4 flex-wrap") - a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-blue-100 transition-colors duration-200" title="微信") - span.streamline-ultimate-color--wechat-logo - // a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-red-100 transition-colors duration-200" title="微博") - span.fa7-brands--weibo - a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-blue-100 transition-colors duration-200" title="QQ") - span.cib--tencent-qq - a(href="#" class="social-link p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors duration-200" title="GitHub") - span.ri--github-fill - a(href="https://blog.xieyaxin.top" target="_blank" class="social-link p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors duration-200" title="GitHub") - span.icomoon-free--blog - - .footer-bottom(class="border-t border-gray-200 pt-6") - .footer-bottom-content(class="flex flex-col md:flex-row justify-between items-center") - .copyright(class="text-gray-500 text-sm mb-4 md:mb-0") - | © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 - .footer-actions(class="flex items-center space-x-6") - a(href="/sitemap" class="text-gray-500 hover:text-blue-600 transition-colors duration-200 text-sm") 网站地图 - a(href="/rss" class="text-gray-500 hover:text-blue-600 transition-colors duration-200 text-sm") RSS订阅 - -block $$scripts - block pageScripts - script. - (function(){ - var navbar = document.querySelector('.navbar'); - var toggle = navbar && navbar.querySelector('.menu-toggle'); - if(toggle){ - toggle.addEventListener('click', function(){ - navbar.classList.toggle('open'); - }); - } - })(); diff --git a/src/views/layouts/page.pug b/src/views/layouts/page.pug deleted file mode 100644 index f6353e1..0000000 --- a/src/views/layouts/page.pug +++ /dev/null @@ -1,31 +0,0 @@ -extends /layouts/base.pug - -block head - +css('styles.css') - block pageHead - -block content - .page-layout - .page - - const navs = []; - - navs.push({ href: '/', label: '首页' }); - - navs.push({ href: '/articles', label: '文章' }); - - navs.push({ href: '/article', label: '收藏' }); - - navs.push({ href: '/about', label: '关于' }); - nav.nav - ul.flota-nav - each nav in navs - li - a.item( - href=nav.href, - class=currentPath === nav.href ? 'active' : '' - ) #{nav.label} - .content - block pageContent - footer - +include() - - var edit = false - include /htmx/footer.pug - -block scripts - block pageScripts diff --git a/src/views/layouts/pure.pug b/src/views/layouts/pure.pug deleted file mode 100644 index 7727749..0000000 --- a/src/views/layouts/pure.pug +++ /dev/null @@ -1,16 +0,0 @@ -extends /layouts/root.pug - -block $$head - +css('styles.css') - block pageHead - -block $$content - .page-layout - .page - .content - block pageContent - footer - include /htmx/footer.pug - -block $$scripts - block pageScripts diff --git a/src/views/layouts/root.pug b/src/views/layouts/root.pug deleted file mode 100644 index 479f568..0000000 --- a/src/views/layouts/root.pug +++ /dev/null @@ -1,69 +0,0 @@ -include utils.pug - -doctype html -html(lang="zh-CN") - head - block $$head - title #{site_title || $site && $site.site_title || ''} - meta(name="description" content=site_description || $site && $site.site_description || '') - meta(name="keywords" content=keywords || $site && $site.keywords || '') - if $site && $site.site_favicon - link(rel="shortcut icon", href=$site.site_favicon) - meta(charset="utf-8") - meta(name="viewport" content="width=device-width, initial-scale=1") - +css('lib/reset.css') - +css('lib/simplebar.css') - +css('lib/simplebar-shim.css') - +css('css/layouts/root.css') - +js('lib/htmx.min.js') - +js('lib/tailwindcss.3.4.17.js') - +js('lib/simplebar.min.js') - body - noscript - style. - .simplebar-content-wrapper { - scrollbar-width: auto; - -ms-overflow-style: auto; - } - - .simplebar-content-wrapper::-webkit-scrollbar, - .simplebar-hide-scrollbar::-webkit-scrollbar { - display: initial; - width: initial; - height: initial; - } - div(data-simplebar style="height: 100%") - div(style="height: 100%; display: flex; flex-direction: column") - block $$content - block $$scripts - script. - //- 处理滚动条位置 - const el = document.querySelector('.simplebar-content-wrapper') - const scrollTop = sessionStorage.getItem('scrollTop-'+location.pathname) - window.onload = function() { - el.scrollTop = scrollTop - el.addEventListener("scroll", function(e) { - sessionStorage.setItem('scrollTop-'+location.pathname, e.target.scrollTop) - }) - } - //- 处理点击慢慢回到顶部 - const backToTopBtn = document.querySelector('.back-to-top'); - if (backToTopBtn) { - backToTopBtn.addEventListener('click', function(e) { - e.preventDefault(); - const el = document.querySelector('.simplebar-content-wrapper'); - if (!el) return; - const duration = 400; - const start = el.scrollTop; - const startTime = performance.now(); - function animateScroll(currentTime) { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - el.scrollTop = start * (1 - progress); - if (progress < 1) { - requestAnimationFrame(animateScroll); - } - } - requestAnimationFrame(animateScroll); - }); - } \ No newline at end of file diff --git a/src/views/layouts/utils.pug b/src/views/layouts/utils.pug deleted file mode 100644 index 7cc90a7..0000000 --- a/src/views/layouts/utils.pug +++ /dev/null @@ -1,23 +0,0 @@ -mixin include() - if block - block -//- include的使用方法 -//- +include() -//- - var edit = false -//- include /htmx/footer.pug - -mixin css(url, extranl = false) - if extranl || url.startsWith('http') || url.startsWith('//') - link(rel="stylesheet" type="text/css" href=url) - else - link(rel="stylesheet", href=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url)) - -mixin js(url, extranl = false) - if extranl || url.startsWith('http') || url.startsWith('//') - script(type="text/javascript" src=url) - else - script(src=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url)) - -mixin link(href, name) - //- attributes == {class: "btn"} - a(href=href)&attributes(attributes)= name \ No newline at end of file diff --git a/src/views/page/about/index.pug b/src/views/page/about/index.pug deleted file mode 100644 index f2b82d7..0000000 --- a/src/views/page/about/index.pug +++ /dev/null @@ -1,20 +0,0 @@ -extends /layouts/bg-page.pug - -block pageContent - .about-container.card - h1 关于我们 - p 我们致力于打造一个基于 Koa3 的现代 Web 示例项目,帮助开发者快速上手高效、可扩展的 Web 应用开发。 - .about-section - h2 我们的愿景 - p 推动 Node.js 生态下的现代 Web 技术发展,降低开发门槛,提升开发体验。 - .about-section - h2 技术栈 - ul - li Koa3 - li Pug 模板引擎 - li 现代前端技术(如 ES6+、CSS3) - .about-section - h2 联系我们 - p 如有建议或合作意向,欢迎通过 - a(href="mailto:1549469775@qq.com") 联系方式 - | 与我们取得联系。 diff --git a/src/views/page/articles/article.pug b/src/views/page/articles/article.pug deleted file mode 100644 index a92df10..0000000 --- a/src/views/page/articles/article.pug +++ /dev/null @@ -1,70 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .container.mx-auto.px-4.py-8 - article.max-w-4xl.mx-auto - header.mb-8 - h1.text-4xl.font-bold.mb-4= article.title - .flex.flex-wrap.items-center.text-gray-600.mb-4 - span.mr-4 - i.fas.fa-calendar-alt.mr-1 - = new Date(article.published_at).toLocaleDateString() - span.mr-4 - i.fas.fa-eye.mr-1 - = article.view_count + " 阅读" - if article.reading_time - span.mr-4 - i.fas.fa-clock.mr-1 - = article.reading_time + " 分钟阅读" - if article.category - a.text-blue-600.mr-4(href=`/articles/category/${article.category}` class="hover:text-blue-800") - i.fas.fa-folder.mr-1 - = article.category - if article.status === "draft" - span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 - - if article.tags - .flex.flex-wrap.gap-2.mb-4 - each tag in article.tags.split(',') - a.bg-gray-100.text-gray-700.px-3.py-1.rounded-full.text-sm(href=`/articles/tag/${tag.trim()}` class="hover:bg-gray-200") - i.fas.fa-tag.mr-1 - = tag.trim() - - if article.featured_image - .mb-8 - img.w-full.rounded-lg.shadow-lg(src=article.featured_image alt=article.title) - - .prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md") - != article.content - - if article.keywords || article.description - .bg-gray-50.rounded-lg.p-6.mb-8 - if article.keywords - .mb-4 - h3.text-lg.font-semibold.mb-2 关键词 - .flex.flex-wrap.gap-2 - each keyword in article.keywords.split(',') - span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim() - if article.description - h3.text-lg.font-semibold.mb-2 描述 - p.text-gray-600= article.description - - if relatedArticles && relatedArticles.length - section.border-t.pt-8.mt-8 - h2.text-2xl.font-bold.mb-6 相关文章 - .grid.grid-cols-1.gap-6(class="md:grid-cols-2") - each related in relatedArticles - .bg-white.shadow-md.rounded-lg.overflow-hidden - if related.featured_image - img.w-full.h-48.object-cover(src=related.featured_image alt=related.title) - .p-6 - h3.text-xl.font-semibold.mb-2 - a(href=`/articles/${related.slug}` class="hover:text-blue-600")= related.title - if related.excerpt - p.text-gray-600.text-sm.mb-4= related.excerpt - .flex.justify-between.items-center.text-sm.text-gray-500 - span - i.fas.fa-calendar-alt.mr-1 - = new Date(related.published_at).toLocaleDateString() - if related.category - a.text-blue-600(href=`/articles/category/${related.category}` class="hover:text-blue-800")= related.category diff --git a/src/views/page/articles/category.pug b/src/views/page/articles/category.pug deleted file mode 100644 index 5881ff3..0000000 --- a/src/views/page/articles/category.pug +++ /dev/null @@ -1,29 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .container.mx-auto.py-8 - h1.text-3xl.font-bold.mb-8 - span.text-gray-600 分类: - = category - - .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") - each article in articles - .bg-white.shadow-md.rounded-lg.overflow-hidden - if article.featured_image - img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) - .p-6 - h2.text-xl.font-semibold.mb-2 - a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title - if article.excerpt - p.text-gray-600.mb-4= article.excerpt - .flex.justify-between.items-center.text-sm.text-gray-500 - span - i.fas.fa-calendar-alt.mr-1 - = new Date(article.published_at).toLocaleDateString() - span - i.fas.fa-eye.mr-1 - = article.view_count + " 阅读" - - if !articles.length - .text-center.py-8 - p.text-gray-500 该分类下暂无文章 diff --git a/src/views/page/articles/index.pug b/src/views/page/articles/index.pug deleted file mode 100644 index 5c4cfeb..0000000 --- a/src/views/page/articles/index.pug +++ /dev/null @@ -1,134 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .flex.flex-col - .flex-1 - .container.mx-auto - // 页头 - .flex.justify-between.items-center.mb-8 - h1.text-2xl.font-bold 文章列表 - .flex.gap-4 - // 搜索框 - .relative - input#searchInput.w-64.pl-10.pr-4.py-2.border.rounded-lg( - type="text" - placeholder="搜索文章..." - hx-get="/articles/search" - hx-trigger="keyup changed delay:500ms" - hx-target="#articleList" - hx-swap="outerHTML" - name="q" - class="focus:outline-none focus:ring-blue-500 focus:ring-2" - ) - i.fas.fa-search.absolute.left-3.top-3.text-gray-400 - - // 视图切换按钮 - //- .flex.items-center.gap-2.bg-white.p-1.rounded-lg.border - //- button.p-2.rounded( - //- class="hover:bg-gray-100" - //- hx-get="/articles?view=grid" - //- hx-target="#articleList" - //- ) - //- i.fas.fa-th-large - //- button.p-2.rounded( - //- class="hover:bg-gray-100" - //- hx-get="/articles?view=list" - //- hx-target="#articleList" - //- ) - //- i.fas.fa-list - - // 筛选栏 - .bg-white.rounded-lg.shadow-sm.p-4.mb-6 - .flex.flex-wrap.gap-4 - if categories && categories.length - .flex.items-center.gap-2 - span.text-gray-600 分类: - each cat in categories - a.px-3.py-1.rounded-full( - class="hover:bg-blue-50 hover:text-blue-600" + (cat === currentCategory ? " bg-blue-100 text-blue-600" : "") - href=`/articles/category/${cat}` - )= cat - - if tags && tags.length - .flex.items-center.gap-2 - span.text-gray-600 标签: - each tag in tags - a.px-3.py-1.rounded-full( - class="hover:bg-blue-50 hover:text-blue-600" + (tag === currentTag ? " bg-blue-100 text-blue-600" : "") - href=`/articles/tag/${tag}` - )= tag - - // 文章列表 - #articleList.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") - each article in articles - .bg-white.rounded-lg.shadow-sm.overflow-hidden.transition.duration-300.transform(class="hover:-translate-y-1 hover:shadow-md") - if article.featured_image - .relative.h-48 - img.w-full.h-full.object-cover(src=article.featured_image alt=article.title) - if article.category - a.absolute.top-3.right-3.px-3.py-1.bg-blue-600.text-white.text-sm.rounded-full.opacity-90( - href=`/articles/category/${article.category}` - class="hover:opacity-100" - )= article.category - .p-6 - h2.text-xl.font-bold.mb-3 - a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title - if article.excerpt - p.text-gray-600.text-sm.mb-4.line-clamp-2= article.excerpt - - .flex.flex-wrap.gap-2.mb-4 - if article.tags - each tag in article.tags.split(',') - a.text-sm.text-gray-500( - href=`/articles/tag/${tag.trim()}` - class="hover:text-blue-600" - ) - i.fas.fa-tag.mr-1 - = tag.trim() - - .flex.justify-between.items-center.text-sm.text-gray-500 - .flex.items-center.gap-4 - span - i.far.fa-calendar.mr-1 - = new Date(article.published_at).toLocaleDateString() - if article.reading_time - span - i.far.fa-clock.mr-1 - = article.reading_time + "分钟" - span - i.far.fa-eye.mr-1 - = article.view_count + " 阅读" - - if !articles.length - .col-span-full.py-16.text-center - .text-gray-400.mb-4 - i.fas.fa-inbox.text-6xl - p.text-gray-500 暂无文章 - - // 分页 - if totalPages > 1 - .flex.justify-center.mt-8 - nav.flex.items-center.gap-1(aria-label="Pagination") - // 上一页 - if currentPage > 1 - a.px-3.py-1.rounded-md.bg-white.border( - href=`/articles?page=${currentPage - 1}` - class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" - ) 上一页 - - // 页码 - each page in Array.from({length: totalPages}, (_, i) => i + 1) - if page === currentPage - span.px-3.py-1.rounded-md.bg-blue-50.text-blue-600.border.border-blue-200= page - else - a.px-3.py-1.rounded-md.bg-white.border( - href=`/articles?page=${page}` - class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" - )= page - - // 下一页 - if currentPage < totalPages - a.px-3.py-1.rounded-md.bg-white.border( - href=`/articles?page=${currentPage + 1}` - class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" - ) 下一页 diff --git a/src/views/page/articles/search.pug b/src/views/page/articles/search.pug deleted file mode 100644 index 65af296..0000000 --- a/src/views/page/articles/search.pug +++ /dev/null @@ -1,34 +0,0 @@ -//- extends /layouts/empty.pug - -//- block pageContent -#articleList.container.mx-auto.px-4.py-8 - .mb-8 - h1.text-3xl.font-bold.mb-4 - span.text-gray-600 搜索结果: - = keyword - p.text-gray-500 找到 #{articles.length} 篇相关文章 - - .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") - each article in articles - .bg-white.shadow-md.rounded-lg.overflow-hidden - if article.featured_image - img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) - .p-6 - h2.text-xl.font-semibold.mb-2 - a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title - if article.excerpt - p.text-gray-600.mb-4= article.excerpt - .flex.justify-between.items-center - .text-sm.text-gray-500 - span.mr-4 - i.fas.fa-calendar-alt.mr-1 - = new Date(article.published_at).toLocaleDateString() - span - i.fas.fa-eye.mr-1 - = article.view_count + " 阅读" - if article.category - a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category - - if !articles.length - .text-center.py-8 - p.text-gray-500 未找到相关文章 diff --git a/src/views/page/articles/tag.pug b/src/views/page/articles/tag.pug deleted file mode 100644 index c780655..0000000 --- a/src/views/page/articles/tag.pug +++ /dev/null @@ -1,32 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .container.mx-auto.py-8 - h1.text-3xl.font-bold.mb-8 - span.text-gray-600 标签: - = tag - - .grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") - each article in articles - .bg-white.shadow-md.rounded-lg.overflow-hidden - if article.featured_image - img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) - .p-6 - h2.text-xl.font-semibold.mb-2 - a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title - if article.excerpt - p.text-gray-600.mb-4= article.excerpt - .flex.justify-between.items-center - .text-sm.text-gray-500 - span.mr-4 - i.fas.fa-calendar-alt.mr-1 - = new Date(article.published_at).toLocaleDateString() - span - i.fas.fa-eye.mr-1 - = article.view_count + " 阅读" - if article.category - a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category - - if !articles.length - .text-center.py-8 - p.text-gray-500 该标签下暂无文章 diff --git a/src/views/page/auth/no-auth.pug b/src/views/page/auth/no-auth.pug deleted file mode 100644 index d578636..0000000 --- a/src/views/page/auth/no-auth.pug +++ /dev/null @@ -1,54 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .no-auth-container - .no-auth-icon - i.fa.fa-lock - h2 访问受限 - p 您没有权限访问此页面,请先登录或联系管理员。 - a.btn(href='/login') 去登录 - -block pageHead - style. - .no-auth-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 60vh; - text-align: center; - } - .no-auth-icon { - font-size: 4em; - color: #ff6a6a; - margin-bottom: 20px; - } - .no-auth-container h2 { - margin: 0 0 10px 0; - color: #333; - } - .no-auth-container p { - color: #888; - margin-bottom: 24px; - } - .no-auth-container .btn { - display: inline-block; - padding: 10px 32px; - background: linear-gradient(90deg, #4fd1ff 0%, #ff6a6a 100%); - color: #fff; - border: none; - border-radius: 24px; - font-size: 1.1em; - text-decoration: none; - transition: background 0.2s; - cursor: pointer; - } - .no-auth-container .btn:hover { - background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%); - color: #fff200; - } - -//- block pageScripts -//- script. - //- const curUrl = URL.parse(location.href).searchParams.get("from") - //- fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e)) \ No newline at end of file diff --git a/src/views/page/extra/contact.pug b/src/views/page/extra/contact.pug deleted file mode 100644 index f334074..0000000 --- a/src/views/page/extra/contact.pug +++ /dev/null @@ -1,83 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - -block pageContent - .contact.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 联系我们 - p(class="text-gray-600 mb-8 text-center text-lg") 我们非常重视您的反馈和建议,欢迎通过以下方式与我们取得联系 - - // 联系信息 - .contact-info(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center justify-center") - span(class="mr-2") 📞 - | 联系方式 - .grid.grid-cols-1.md:grid-cols-3.gap-6 - .contact-card(class="text-center p-6 bg-blue-50 rounded-lg border border-blue-200 hover:shadow-md transition-shadow") - .icon(class="text-4xl mb-3") 📧 - h3(class="font-semibold text-blue-800 mb-2") 邮箱联系 - p(class="text-gray-700 mb-2") support@example.com - p(class="text-sm text-gray-500") 工作日 24 小时内回复 - .contact-card(class="text-center p-6 bg-green-50 rounded-lg border border-green-200 hover:shadow-md transition-shadow") - .icon(class="text-4xl mb-3") 💬 - h3(class="font-semibold text-green-800 mb-2") 在线客服 - p(class="text-gray-700 mb-2") 工作日 9:00-18:00 - p(class="text-sm text-gray-500") 实时在线解答 - .contact-card(class="text-center p-6 bg-purple-50 rounded-lg border border-purple-200 hover:shadow-md transition-shadow") - .icon(class="text-4xl mb-3") 📱 - h3(class="font-semibold text-purple-800 mb-2") 社交媒体 - p(class="text-gray-700 mb-2") 微信、QQ、GitHub - p(class="text-sm text-gray-500") 关注获取最新动态 - - // 联系表单 - .contact-form(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center justify-center") - span(class="mr-2") ✍️ - | 留言反馈 - .form-container(class="max-w-2xl mx-auto") - form(action="/contact" method="POST" class="space-y-4") - .form-group(class="grid grid-cols-1 md:grid-cols-2 gap-4") - .input-group - label(for="name" class="block text-sm font-medium text-gray-700 mb-1") 姓名 * - input#name(type="text" name="name" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") - .input-group - label(for="email" class="block text-sm font-medium text-gray-700 mb-1") 邮箱 * - input#email(type="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") - .form-group - label(for="subject" class="block text-sm font-medium text-gray-700 mb-1") 主题 * - select#subject(name="subject" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") - option(value="") 请选择反馈类型 - option(value="bug") 问题反馈 - option(value="feature") 功能建议 - option(value="content") 内容相关 - option(value="other") 其他 - .form-group - label(for="message" class="block text-sm font-medium text-gray-700 mb-1") 留言内容 * - textarea#message(name="message" rows="5" required placeholder="请详细描述您的问题或建议..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical") - .form-group(class="text-center") - button(type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors") 提交留言 - - // 办公地址 - .office-info(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center justify-center") - span(class="mr-2") 🏢 - | 办公地址 - .office-card(class="max-w-2xl mx-auto p-6 bg-gray-50 rounded-lg border border-gray-200") - .office-details(class="text-center") - h3(class="font-semibold text-gray-800 mb-2") 公司总部 - p(class="text-gray-700 mb-2") 北京市朝阳区某某大厦 - p(class="text-gray-700 mb-2") 邮编:100000 - p(class="text-sm text-gray-500") 工作时间:周一至周五 9:00-18:00 - - // 相关链接 - .contact-links(class="text-center pt-6 border-t border-gray-200") - p(class="text-gray-600 mb-3") 更多帮助资源: - .links(class="flex flex-wrap justify-center gap-4") - a(href="/help" class="text-blue-600 hover:text-blue-800 hover:underline") 帮助中心 - a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 - a(href="/feedback" class="text-blue-600 hover:text-blue-800 hover:underline") 意见反馈 - a(href="/about" class="text-blue-600 hover:text-blue-800 hover:underline") 关于我们 - - .contact-footer(class="text-center mt-8 pt-6 border-t border-gray-200") - p(class="text-gray-500 text-sm") 我们承诺保护您的隐私,所有联系信息仅用于回复您的反馈 - p(class="text-gray-400 text-xs mt-2") 感谢您的支持与信任 diff --git a/src/views/page/extra/faq.pug b/src/views/page/extra/faq.pug deleted file mode 100644 index 5b0761b..0000000 --- a/src/views/page/extra/faq.pug +++ /dev/null @@ -1,55 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - -block pageContent - .faq.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-2xl font-bold mb-4") 常见问题(FAQ) - p(class="text-gray-600 mb-6") 为帮助您快速了解与使用本站,这里汇总了常见问答。 - - // 基础使用 - h2(class="text-xl font-semibold mt-6 mb-3") 一、基础使用 - dl.divide-y.divide-gray-100 - div.py-4 - dt.font-medium 我如何注册与登录? - dd.text-gray-700.mt-1 访问“注册/登录”页面,按提示完成信息填写即可。如遇验证码问题,请刷新或稍后重试。 - div.py-4 - dt.font-medium 忘记密码怎么办? - dd.text-gray-700.mt-1 目前暂未开放自助找回功能,请通过页脚联系方式与我们取得联系协助处理。 - - // 账号与安全 - h2(class="text-xl font-semibold mt-6 mb-3") 二、账号与安全 - dl.divide-y.divide-gray-100 - div.py-4 - dt.font-medium 如何提升账户安全? - dd.text-gray-700.mt-1 使用强密码、定期更换、不在公共设备保存登录信息,退出时及时登出。 - div.py-4 - dt.font-medium 我的数据会被如何使用? - dd.text-gray-700.mt-1 我们严格遵循最小必要与合规原则处理数据,详见 - a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 - | 。 - - // 功能与服务 - h2(class="text-xl font-semibold mt-6 mb-3") 三、功能与服务 - dl.divide-y.divide-gray-100 - div.py-4 - dt.font-medium 你们提供哪些公开 API? - dd.text-gray-700.mt-1 可在首页“API 列表”中查看示例与说明,或关注文档更新。 - div.py-4 - dt.font-medium 页面打不开/出现错误怎么办? - dd.text-gray-700.mt-1 刷新页面、清理缓存或更换网络环境;如仍有问题,请将报错信息与时间反馈给我们。 - - // 合规与条款 - h2(class="text-xl font-semibold mt-6 mb-3") 四、合规与条款 - dl.divide-y.divide-gray-100 - div.py-4 - dt.font-medium 需要遵守哪些条款? - dd.text-gray-700.mt-1 使用前请阅读并同意 - a(href="/terms" class="text-blue-600 hover:underline") 服务条款 - | 与 - a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 - | 。 - - p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 - - diff --git a/src/views/page/extra/feedback.pug b/src/views/page/extra/feedback.pug deleted file mode 100644 index 985b18b..0000000 --- a/src/views/page/extra/feedback.pug +++ /dev/null @@ -1,28 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - -block pageContent - .feedback.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-2xl font-bold mb-2") 意见反馈 - p(class="text-gray-600 mb-6") 欢迎提出您的建议或问题,我们会尽快处理。 - - form(class="space-y-4" method="post" action="#" onsubmit="alert('感谢反馈!'); return false;") - .grid.grid-cols-1(class="md:grid-cols-2 gap-4") - .form-item - label.block.text-sm.text-gray-600.mb-1(for="name") 您的称呼 - input#name(type="text" name="name" placeholder="例如:张三" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") - .form-item - label.block.text-sm.text-gray-600.mb-1(for="email") 邮箱(可选) - input#email(type="email" name="email" placeholder="用于回复您" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") - .form-item - label.block.text-sm.text-gray-600.mb-1(for="subject") 主题 - input#subject(type="text" name="subject" placeholder="简要概括问题/建议" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") - .form-item - label.block.text-sm.text-gray-600.mb-1(for="content") 详细描述 - textarea#content(name="content" rows="6" placeholder="请尽量描述清楚场景、复现步骤、预期与实际结果等" class="w-full border border-gray-200 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200") - .flex.items-center.justify-between - button(type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors") 提交反馈 - a(href="mailto:me@xieyaxin.top" class="text-sm text-gray-500 hover:text-blue-600") 或发送邮件联系 - - diff --git a/src/views/page/extra/help.pug b/src/views/page/extra/help.pug deleted file mode 100644 index 84a8d5d..0000000 --- a/src/views/page/extra/help.pug +++ /dev/null @@ -1,97 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - -block pageContent - .help.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 帮助中心 - p(class="text-gray-600 mb-8 text-center text-lg") 欢迎使用帮助中心,这里为您提供完整的使用指南和问题解答 - - // 快速入门 - .help-section(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center") - span(class="mr-2") 🚀 - | 快速入门 - .grid.grid-cols-1(class="md:grid-cols-2 gap-4") - .help-card(class="p-4 bg-blue-50 rounded-lg border border-blue-200") - h3(class="font-semibold text-blue-800 mb-2") 注册登录 - p(class="text-sm text-gray-700") 点击右上角"注册"按钮,填写基本信息即可创建账户 - .help-card(class="p-4 bg-green-50 rounded-lg border border-green-200") - h3(class="font-semibold text-green-800 mb-2") 浏览文章 - p(class="text-sm text-gray-700") 在首页或文章页面浏览各类精彩内容 - .help-card(class="p-4 bg-purple-50 rounded-lg border border-purple-200") - h3(class="font-semibold text-purple-800 mb-2") 收藏管理 - p(class="text-sm text-gray-700") 点击文章下方的收藏按钮,在个人中心管理收藏 - .help-card(class="p-4 bg-orange-50 rounded-lg border border-orange-200") - h3(class="font-semibold text-orange-800 mb-2") 个人设置 - p(class="text-sm text-gray-700") 在个人中心修改头像、密码等账户信息 - - // 功能指南 - .help-section(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center") - span(class="mr-2") 📚 - | 功能指南 - .help-features(class="space-y-4") - .feature-item(class="p-4 bg-gray-50 rounded-lg") - h3(class="font-semibold text-gray-800 mb-2") 文章阅读 - p(class="text-gray-700 text-sm") 支持多种格式的文章阅读,提供舒适的阅读体验。可以调整字体大小、切换主题等。 - .feature-item(class="p-4 bg-gray-50 rounded-lg") - h3(class="font-semibold text-gray-800 mb-2") 智能搜索 - p(class="text-gray-700 text-sm") 使用关键词搜索文章内容,支持模糊匹配和标签筛选。 - .feature-item(class="p-4 bg-gray-50 rounded-lg") - h3(class="font-semibold text-gray-800 mb-2") 收藏夹 - p(class="text-gray-700 text-sm") 创建个人收藏夹,分类管理感兴趣的内容,支持标签和备注功能。 - - // 常见问题 - .help-section(class="mb-8") - h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center") - span(class="mr-2") ❓ - | 常见问题 - .faq-list(class="space-y-3") - details(class="group") - summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") - | 如何修改密码? - .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") - | 登录后进入个人中心 → 账户安全 → 修改密码,输入原密码和新密码即可。 - - details(class="group") - summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") - | 忘记密码怎么办? - .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") - | 请联系客服协助处理,提供注册时的邮箱或手机号进行身份验证。 - - details(class="group") - summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") - | 如何批量管理收藏? - .faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") - | 在个人中心的收藏页面,可以选择多个项目进行批量删除或移动操作。 - - // 联系支持 - .help-section(class="mb-6") - h2(class="text-2xl font-semibold mb-4 text-red-600 flex items-center") - span(class="mr-2") 📞 - | 联系支持 - .support-info(class="grid grid-cols-1 md:grid-cols-3 gap-4") - .support-item(class="text-center p-4 bg-red-50 rounded-lg") - h3(class="font-semibold text-red-800 mb-2") 在线客服 - p(class="text-sm text-gray-700") 工作日 9:00-18:00 - .support-item(class="text-center p-4 bg-red-50 rounded-lg") - h3(class="font-semibold text-red-800 mb-2") 邮箱支持 - p(class="text-sm text-gray-700") support@example.com - .support-item(class="text-center p-4 bg-red-50 rounded-lg") - h3(class="font-semibold text-red-800 mb-2") 反馈建议 - p(class="text-sm text-gray-700") - a(href="/feedback" class="text-blue-600 hover:underline") 意见反馈页面 - - // 相关链接 - .help-links(class="text-center pt-6 border-t border-gray-200") - p(class="text-gray-600 mb-3") 更多帮助资源: - .links(class="flex flex-wrap justify-center gap-4") - a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 - a(href="/terms" class="text-blue-600 hover:text-blue-800 hover:underline") 服务条款 - a(href="/privacy" class="text-blue-600 hover:text-blue-800 hover:underline") 隐私政策 - a(href="/contact" class="text-blue-600 hover:text-blue-800 hover:underline") 联系我们 - - .help-footer(class="text-center mt-8 pt-6 border-t border-gray-200") - p(class="text-gray-500 text-sm") 最后更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 - p(class="text-gray-400 text-xs mt-2") 如有其他问题,欢迎随时联系我们 diff --git a/src/views/page/extra/privacy.pug b/src/views/page/extra/privacy.pug deleted file mode 100644 index 89927f7..0000000 --- a/src/views/page/extra/privacy.pug +++ /dev/null @@ -1,75 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .privacy.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-2xl font-bold mb-4") 隐私政策 - p(class="text-gray-600 mb-6") 我们重视您的个人信息与隐私保护。本隐私政策旨在向您说明我们如何收集、使用、共享与保护您的信息,以及您对个人信息享有的权利。请您在使用本站服务前仔细阅读并充分理解本政策的全部内容。 - - h2(class="text-xl font-semibold mt-6 mb-3") 一、适用范围 - ul.list-disc.pl-6.text-gray-700 - li 当您访问、浏览、注册、登录、使用本站提供的各项产品/服务时,本政策适用。 - li 如与《服务条款》存在不一致,以本政策就个人信息处理相关内容为准。 - - h2(class="text-xl font-semibold mt-6 mb-3") 二、我们收集的信息 - p 为向您提供服务与优化体验,我们可能收集以下信息: - ul.list-disc.pl-6.text-gray-700 - li 账户信息:昵称、头像、联系方式(如邮箱、手机)、密码或凭证等。 - li 使用信息:访问记录、点击行为、浏览历史、设备信息(设备型号、操作系统、浏览器类型、分辨率)、网络信息(IP、运营商)。 - li 日志信息:错误日志、性能日志、系统事件,以便排查问题和提升稳定性。 - li 交互信息:您与我们沟通时提交的反馈、工单、评论与表单信息。 - - h2(class="text-xl font-semibold mt-6 mb-3") 三、信息的来源与收集方式 - ul.list-disc.pl-6.text-gray-700 - li 您主动提供:注册、填写表单、提交反馈时提供的相关信息。 - li 自动收集:通过 Cookie/本地存储、日志与统计分析工具自动采集的使用数据与设备信息。 - li 第三方来源:在您授权的前提下,我们可能从依法合规的第三方获取必要信息以完善服务。 - - h2(class="text-xl font-semibold mt-6 mb-3") 四、我们如何使用信息 - ul.list-disc.pl-6.text-gray-700 - li 提供、维护与优化产品/服务的功能与性能。 - li 账号管理、身份验证、安全防护与风险控制。 - li 向您发送与服务相关的通知(如更新、变更、异常提示)。 - li 数据统计与分析,用于改善产品体验与用户支持。 - li 依法需配合的监管合规、争议处理与维权。 - - h2(class="text-xl font-semibold mt-6 mb-3") 五、Cookie 与本地存储 - p 为确保基础功能和提升体验,我们会使用 Cookie 或浏览器本地存储: - ul.list-disc.pl-6.text-gray-700 - li 目的:会话保持、偏好设置、性能与功能分析。 - li 管理:您可在浏览器设置中清除或禁止 Cookie;但部分功能可能因此受限或不可用。 - - h2(class="text-xl font-semibold mt-6 mb-3") 六、信息共享、转让与公开披露 - ul.list-disc.pl-6.text-gray-700 - li 我们不会向无关第三方出售您的个人信息。 - li 仅在以下情形共享或转让:获得您明确同意;基于法律法规、司法或行政机关要求;为实现功能所必需的可信合作伙伴(最小必要原则并签署保密与数据保护协议)。 - li 公开披露仅在法律要求或为保护重大公共利益、他人生命财产安全等必要情形下进行。 - - h2(class="text-xl font-semibold mt-6 mb-3") 七、第三方服务与 SDK - p 本站可能集成第三方服务(如统计分析、支付、登录、地图等)。第三方可能独立收集、处理您的信息,其行为受其自身隐私政策约束。我们将审慎评估接入必要性并尽可能要求其遵循最小必要、去标识化与安全合规。 - - h2(class="text-xl font-semibold mt-6 mb-3") 八、信息的存储与安全 - ul.list-disc.pl-6.text-gray-700 - li 存储地点与期限:信息通常存储在依法设立的服务器中,保存期限以实现目的所需的最短时间为准,法律法规另有要求的从其规定。 - li 安全措施:采用访问控制、加密传输/存储、最小权限、定期审计与备份等措施,降低信息泄露、损毁、丢失风险。 - li 事件响应:一旦发生安全事件,我们将按照法律法规履行告知与处置义务。 - - h2(class="text-xl font-semibold mt-6 mb-3") 九、您的权利 - ul.list-disc.pl-6.text-gray-700 - li 访问、更正与删除:在不影响其他自然人合法权益及法律留存要求的前提下,您可按照指引访问、更正或删除相关信息。 - li 撤回同意与注销账户:您可撤回非必要信息处理的授权,或申请注销账户(法律法规另有规定或为履行合同所必需的除外)。 - li 获取副本与可携权(如适用):在符合法律条件时,您可请求导出个人信息副本。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十、未成年人保护 - p 未成年人应在监护人监护、指导下使用本站服务。若您是监护人并对未成年人信息有疑问,请与我们联系,我们将在核实后尽快处理。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十一、跨境传输(如适用) - p 如涉及将您的个人信息传输至境外,我们会依据适用法律履行必要评估、备案与合同保障义务,并确保接收方具备足够的数据保护能力。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十二、本政策的更新 - p 为适应业务、技术或法律法规变化,我们可能对本政策进行更新。重大变更将以显著方式提示。您继续使用服务即视为接受更新后的政策。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十三、联系我们 - p 如您对本政策或个人信息保护有任何疑问、意见或请求,请通过页脚中的联系方式与我们取得联系,我们将尽快予以回复。 - - p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 - diff --git a/src/views/page/extra/terms.pug b/src/views/page/extra/terms.pug deleted file mode 100644 index a64d456..0000000 --- a/src/views/page/extra/terms.pug +++ /dev/null @@ -1,64 +0,0 @@ -extends /layouts/empty.pug - -block pageContent - .terms.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") - h1(class="text-2xl font-bold mb-4") 服务条款 - p(class="text-gray-600 mb-6") 欢迎使用本网站与相关服务。为保障您的合法权益,请在使用前仔细阅读并充分理解本服务条款。 - - h2(class="text-xl font-semibold mt-6 mb-3") 一、协议的接受与变更 - p 本条款构成您与本站之间就使用本站服务所达成的协议。一旦您访问或使用本站,即视为您已阅读并同意受本条款约束。本站有权根据业务需要对条款进行修订,修订后的条款将通过页面公示或其他适当方式通知,若您继续使用服务,即视为接受修订内容。 - - h2(class="text-xl font-semibold mt-6 mb-3") 二、账户注册与使用 - ul.list-disc.pl-6.text-gray-700 - li 您应当具备完全民事行为能力;如不具备,请确保在监护人指导下使用。 - li 注册信息应真实、准确、完整,并在变更时及时更新。 - li 您应妥善保管账户与密码,因保管不善导致的损失由您自行承担。 - li 发现任何未经授权的使用行为,请立即与我们联系。 - - h2(class="text-xl font-semibold mt-6 mb-3") 三、用户行为规范 - ul.list-disc.pl-6.text-gray-700 - li 遵守法律法规,不得利用本站制作、复制、发布、传播违法违规内容。 - li 不得干扰、破坏本站正常运营,不得进行未经授权的访问、抓取或数据采集。 - li 不得对本站进行逆向工程、反编译或尝试获取源代码。 - li 尊重他人合法权益,不得侵犯他人知识产权、隐私权、名誉权等。 - - h2(class="text-xl font-semibold mt-6 mb-3") 四、内容与知识产权 - ul.list-disc.pl-6.text-gray-700 - li 除非另有说明,本站及其内容(包括但不限于文字、图片、界面、版式、程序、数据等)受相关法律保护。 - li 未经授权,任何人不得以任何方式使用、复制、修改、传播或用于商业目的。 - li 用户在本站发布或上传的内容,用户应保证拥有相应权利且不侵犯任何第三方权益。 - - h2(class="text-xl font-semibold mt-6 mb-3") 五、隐私与数据保护 - p 我们将依法收集、使用、存储与保护您的个人信息。更多细则请参见 - a(href="/privacy" class="text-blue-600 hover:underline") 隐私政策 - | 。 - - h2(class="text-xl font-semibold mt-6 mb-3") 六、第三方服务 - p 本站可能集成或链接第三方服务。您对第三方服务的使用应遵循其各自的条款与政策,由此产生的纠纷与责任由您与第三方自行解决。 - - h2(class="text-xl font-semibold mt-6 mb-3") 七、服务变更、中断与终止 - ul.list-disc.pl-6.text-gray-700 - li 因系统维护、升级或不可抗力等原因,本站有权对服务进行变更、中断或终止。 - li 对于免费服务,本站不对中断或终止承担任何赔偿责任;对付费服务,将依据法律法规与约定处理。 - - h2(class="text-xl font-semibold mt-6 mb-3") 八、免责声明 - ul.list-disc.pl-6.text-gray-700 - li 本站以“现状”与“可得”基础提供服务,不对服务的准确性、完整性、持续性做出明示或暗示保证。 - li 因网络故障、设备故障、黑客攻击、不可抗力等造成的服务中断或数据丢失,本站不承担由此产生的损失责任。 - - h2(class="text-xl font-semibold mt-6 mb-3") 九、违约处理 - p 如您违反本条款或相关法律法规,本站有权采取包括但不限于限制功能、冻结或注销账号、删除内容、追究法律责任等措施。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十、适用法律与争议解决 - p 本条款的订立、执行与解释及争议的解决,适用中华人民共和国法律。因本条款产生的任何争议,双方应友好协商解决;协商不成的,提交本站所在地有管辖权的人民法院诉讼解决。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十一、未成年人保护 - p 未成年人应在监护人监护、指导下使用本站服务。监护人应承担监护责任,合理监督未成年人上网行为。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十二、条款的可分割性 - p 如本条款任何条款被认定为无效或不可执行,其余条款仍然有效并对双方具有约束力。 - - h2(class="text-xl font-semibold mt-6 mb-3") 十三、联系与通知 - p 如您对本条款有任何疑问或需要联系本站,请通过页脚中的联系方式与我们取得联系。 - - p(class="text-gray-500 text-sm mt-8") 最近更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 diff --git a/src/views/page/index copy/index.pug b/src/views/page/index copy/index.pug deleted file mode 100644 index 97b371c..0000000 --- a/src/views/page/index copy/index.pug +++ /dev/null @@ -1,10 +0,0 @@ -extends /layouts/page.pug - -block pageHead - +css("css/page/index.css") - -block pageContent - .card.home-hero - h1 #{$site.site_title} - p.subtitle #{$site.site_description} - diff --git a/src/views/page/index/index copy 2.pug b/src/views/page/index/index copy 2.pug deleted file mode 100644 index c7ce24a..0000000 --- a/src/views/page/index/index copy 2.pug +++ /dev/null @@ -1,11 +0,0 @@ -extends /layouts/bg-page.pug - -block pageHead - +css("css/page/index.css") - -block pageContent - div(class="mt-[20px]") - +include() - include /htmx/navbar.pug - .card(class="mt-[20px]") - img(src="/static/bg2.webp" alt="bg") \ No newline at end of file diff --git a/src/views/page/index/index copy.pug b/src/views/page/index/index copy.pug deleted file mode 100644 index 6c53ce1..0000000 --- a/src/views/page/index/index copy.pug +++ /dev/null @@ -1,17 +0,0 @@ -extends /layouts/pure.pug - -block pageHead - +css("css/page/index.css") - -block pageContent - .home-hero - .avatar-container - .author #{$site.site_author} - img.avatar(src=$site.site_author_avatar, alt="") - .card - div 人生轨迹 - +include() - - var timeLine = [{icon: "第一份工作",title: "???", desc: `做游戏的。`, } ] - include /htmx/timeline.pug - //- div(hx-get="/htmx/timeline" hx-trigger="load") - //- div(style="text-align:center;color:white") Loading diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug deleted file mode 100644 index c543dd2..0000000 --- a/src/views/page/index/index.pug +++ /dev/null @@ -1,69 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - +css('css/page/index.css') - +css('https://unpkg.com/tippy.js@5/dist/backdrop.css') - +js("https://unpkg.com/popper.js@1") - +js("https://unpkg.com/tippy.js@5") - -mixin item(url, desc) - a(href=url target="_blank" class="inline-flex items-center text-[16px] p-[10px] rounded-[10px] shadow") - block - .material-symbols-light--info-rounded(data-tippy-content=desc) - -mixin card(blog) - .article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100") - h3.article-title(class="text-lg font-semibold text-gray-900 mb-2") - div(class="transition-colors duration-200") #{blog.title} - if blog.status === "draft" - span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 - p.article-meta(class="text-sm text-gray-400 mb-3 flex") - span(class="mr-2 line-clamp-1" title=blog.author) - span 作者: - span(class="transition-colors duration-200") #{blog.author} - span(class="mr-2 whitespace-nowrap") - span | - span(class="transition-colors duration-200") #{blog.updated_at.slice(0, 10)} - span(class="mr-2 whitespace-nowrap") - span | 分类: - a(href=`/articles/category/${blog.category}` class="hover:text-blue-600 transition-colors duration-200") #{blog.category} - p.article-desc( - class="text-gray-600 text-base mb-4 line-clamp-2" - style="height: 2.8em; overflow: hidden;" - ) - | #{blog.description} - a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → - -mixin empty() - .div-placeholder(class="h-[100px] w-full bg-gray-100 text-center flex items-center justify-center text-[16px]") - block - -block pageContent - div - h2(class="text-[20px] font-bold mb-[10px]") 接口列表 - if apiList && apiList.length > 0 - .api.list - each api in apiList - +item(api.url, api.desc) #{api.name} - else - +empty() 空 - div(class="mt-[20px]") - h2(class="text-[20px] font-bold mb-[10px]") 文章列表 - if blogs && blogs.length > 0 - .blog.list - each blog in blogs - +card(blog) - else - +empty() 文章数据为空 - div(class="mt-[20px]") - h2(class="text-[20px] font-bold mb-[10px]") 收藏列表 - if collections && collections.length > 0 - .blog.list - each collection in collections - +card(collection) - else - +empty() 收藏列表数据为空 - -block pageScripts - script. - tippy('[data-tippy-content]'); \ No newline at end of file diff --git a/src/views/page/index/person.pug b/src/views/page/index/person.pug deleted file mode 100644 index a78eb26..0000000 --- a/src/views/page/index/person.pug +++ /dev/null @@ -1,9 +0,0 @@ -extends /layouts/pure.pug - -block pageHead - +css("css/page/index.css") - -block pageContent - +include() - - let timeLine = [{icon: '11',title: "aaaa",desc:"asd"}] - include /htmx/timeline.pug \ No newline at end of file diff --git a/src/views/page/login/index.pug b/src/views/page/login/index.pug deleted file mode 100644 index 796f94f..0000000 --- a/src/views/page/login/index.pug +++ /dev/null @@ -1,19 +0,0 @@ -extends /layouts/empty.pug - -block pageScripts - script(src="js/login.js") - -block pageContent - .flex.items-center.justify-center.bg-base-200.h-full - .w-full.max-w-md.bg-base-100.shadow-xl.rounded-xl.p-8 - h2.text-2xl.font-bold.text-center.mb-6.text-base-content 登录 - form#login-form(action="/login" method="post" class="space-y-5") - .form-group - label(for="username" class="block mb-1 text-base-content") 用户名 - input#username(type="text" name="username" placeholder="请输入用户名" required class="input input-bordered w-full") - .form-group - label(for="password" class="block mb-1 text-base-content") 密码 - input#password(type="password" name="password" placeholder="请输入密码" required class="input input-bordered w-full") - button.login-btn(type="submit" class="btn btn-primary w-full") 登录 - if error - .login-error.mt-4.text-error.text-center= error diff --git a/src/views/page/notice/index.pug b/src/views/page/notice/index.pug deleted file mode 100644 index ae96700..0000000 --- a/src/views/page/notice/index.pug +++ /dev/null @@ -1,7 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - - -block pageContent - div 这里是通知界面 \ No newline at end of file diff --git a/src/views/page/profile/index.pug b/src/views/page/profile/index.pug deleted file mode 100644 index f0fc9d0..0000000 --- a/src/views/page/profile/index.pug +++ /dev/null @@ -1,625 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - style. - .profile-container { - max-width: 1200px; - margin: 20px auto; - background: #fff; - border-radius: 16px; - box-shadow: 0 4px 24px rgba(0,0,0,0.1); - overflow: hidden; - display: flex; - min-height: 600px; - } - - .profile-sidebar { - width: 320px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 40px 24px; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - } - - .profile-avatar { - width: 120px; - height: 120px; - border-radius: 50%; - background: rgba(255,255,255,0.2); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 24px; - border: 4px solid rgba(255,255,255,0.3); - overflow: hidden; - } - - .profile-avatar img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 50%; - } - - .profile-avatar .avatar-placeholder { - font-size: 3rem; - color: rgba(255,255,255,0.8); - } - - .profile-name { - font-size: 1.5rem; - font-weight: 600; - margin: 0 0 8px 0; - } - - .profile-username { - font-size: 1rem; - opacity: 0.9; - margin: 0 0 16px 0; - background: rgba(255,255,255,0.2); - padding: 6px 16px; - border-radius: 20px; - } - - .profile-bio { - font-size: 0.9rem; - opacity: 0.8; - line-height: 1.5; - margin: 0; - max-width: 250px; - } - - .profile-stats { - margin-top: 32px; - width: 100%; - } - - .stat-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid rgba(255,255,255,0.2); - } - - .stat-item:last-child { - border-bottom: none; - } - - .stat-label { - font-size: 0.85rem; - opacity: 0.8; - } - - .stat-value { - font-weight: 600; - font-size: 0.9rem; - } - - .profile-main { - flex: 1; - padding: 40px 32px; - background: #f8fafc; - } - - .profile-header { - margin-bottom: 32px; - } - - .main-title { - font-size: 2rem; - font-weight: 700; - color: #1e293b; - margin: 0 0 8px 0; - } - - .main-subtitle { - color: #64748b; - font-size: 1rem; - margin: 0; - } - - // 标签页样式 - .profile-tabs { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0,0,0,0.05); - border: 1px solid #e2e8f0; - overflow: hidden; - } - - .tab-nav { - display: flex; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - } - - .tab-btn { - flex: 1; - padding: 16px 24px; - background: none; - border: none; - font-size: 1rem; - font-weight: 500; - color: #64748b; - cursor: pointer; - transition: all 0.2s ease; - position: relative; - } - - .tab-btn:hover { - background: #f1f5f9; - color: #334155; - } - - .tab-btn.active { - background: white; - color: #1e293b; - font-weight: 600; - } - - .tab-btn.active::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); - } - - .tab-content { - padding: 32px; - } - - .tab-pane { - display: none; - } - - .tab-pane.active { - display: block; - } - - .profile-content { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 32px; - } - - .profile-section { - background: white; - border-radius: 12px; - padding: 28px; - box-shadow: 0 2px 8px rgba(0,0,0,0.05); - border: 1px solid #e2e8f0; - } - - .section-title { - font-size: 1.25rem; - font-weight: 600; - color: #1e293b; - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 2px solid #e2e8f0; - position: relative; - display: flex; - align-items: center; - } - - .section-title::before { - content: ''; - width: 4px; - height: 20px; - background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); - border-radius: 2px; - margin-right: 12px; - } - - .form-group { - margin-bottom: 20px; - } - - .form-group:last-child { - margin-bottom: 0; - } - - .form-label { - display: block; - margin-bottom: 8px; - color: #374151; - font-size: 0.9rem; - font-weight: 500; - } - - .form-input, - .form-textarea { - width: 100%; - padding: 12px 16px; - border: 2px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - background: #f9fafb; - transition: all 0.2s ease; - box-sizing: border-box; - } - - .form-input:focus, - .form-textarea:focus { - border-color: #667eea; - outline: none; - background: #fff; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); - } - - .form-textarea { - resize: vertical; - min-height: 100px; - font-family: inherit; - } - - .form-actions { - display: flex; - gap: 12px; - margin-top: 24px; - padding-top: 20px; - border-top: 1px solid #e5e7eb; - } - - .btn { - padding: 10px 20px; - border: none; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 100px; - } - - .btn-primary { - background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); - color: white; - } - - .btn-primary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); - } - - .btn-secondary { - background: #6b7280; - color: white; - } - - .btn-secondary:hover { - background: #4b5563; - transform: translateY(-1px); - } - - .info-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - margin-top: 20px; - } - - .info-item { - background: #f8fafc; - padding: 16px; - border-radius: 8px; - border: 1px solid #e2e8f0; - display: flex; - justify-content: space-between; - align-items: center; - } - - .info-label { - font-size: 0.875rem; - color: #64748b; - } - - .info-value { - font-size: 0.9rem; - color: #1e293b; - font-weight: 500; - } - - .message { - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 16px; - font-weight: 500; - display: none; - } - - .message.show { - display: block !important; - } - - .message-container { - margin-bottom: 16px; - } - - .message.success { - background-color: #d1fae5; - color: #065f46; - border: 1px solid #a7f3d0; - } - - .message.error { - background-color: #fee2e2; - color: #991b1b; - border: 1px solid #fecaca; - } - - .message.info { - background-color: #dbeafe; - color: #1e40af; - border: 1px solid #bfdbfe; - } - - .loading { - opacity: 0.6; - pointer-events: none; - } - - .loading::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 20px; - height: 20px; - margin: -10px 0 0 -10px; - border: 2px solid #f3f3f3; - border-top: 2px solid #667eea; - border-radius: 50%; - animation: spin 1s linear infinite; - } - - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - - .form-input.error, - .form-textarea.error { - border-color: #ef4444; - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); - } - - .error-message { - color: #ef4444; - font-size: 0.8rem; - margin-top: 6px; - display: none; - } - - .error-message.show { - display: block; - } - - @media (max-width: 1024px) { - .profile-container { - flex-direction: column; - margin: 20px; - } - - .profile-sidebar { - width: 100%; - padding: 32px 24px; - } - - .profile-content { - grid-template-columns: 1fr; - gap: 24px; - } - - .profile-main { - padding: 32px 24px; - } - } - - @media (max-width: 768px) { - .profile-container { - margin: 16px; - border-radius: 12px; - } - - .profile-sidebar { - padding: 24px 20px; - } - - .profile-main { - padding: 24px 20px; - } - - .profile-content { - gap: 20px; - } - - .profile-section { - padding: 24px 20px; - } - - .form-actions { - flex-direction: column; - } - - .btn { - width: 100%; - } - } - -block pageContent - form(action="/profile/upload-avatar" method="post" enctype="multipart/form-data") - input(type="file", name="avatar", accept="image/*" onchange="document.getElementById('upload-btn').click()") - button#upload-btn(type="submit") 上传头像 - .profile-container - .profile-sidebar - .profile-avatar - if user.avatar - img(src=user.avatar alt="用户头像") - else - .avatar-placeholder 👤 - - h2.profile-name #{user.name || user.username || '用户'} - .profile-username @#{user.username || 'username'} - - if user.bio - p.profile-bio #{user.bio} - else - p.profile-bio 这个人很懒,还没有写个人简介... - - .profile-stats - .stat-item - span.stat-label 用户ID - span.stat-value #{user.id || 'N/A'} - - .stat-item - span.stat-label 注册时间 - span.stat-value #{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : 'N/A'} - - .stat-item - span.stat-label 用户角色 - span.stat-value #{user.role || 'user'} - - .profile-main - .profile-header - h1.main-title 个人资料设置 - p.main-subtitle 管理您的个人信息和账户安全 - - .profile-tabs - .tab-nav - button.tab-btn.active(data-tab="basic") 基本信息 - button.tab-btn(data-tab="security") 账户安全 - - .tab-content - // 基本信息标签页 - .tab-pane.active#basic-tab - .profile-section - h2.section-title 基本信息 - form#profileForm(action="/profile/update", method="POST") - // 消息提示区域 - .message-container - .message.success#profileMessage - span 资料更新成功! - button.message-close(type="button" onclick="closeMessage('profileMessage')") × - .message.error#profileError - span#profileErrorMessage 更新失败,请重试 - button.message-close(type="button" onclick="closeMessage('profileError')") × - - .form-group - label.form-label(for="username") 用户名 * - input.form-input#username( - type="text" - name="username" - value=user.username || '' - required - placeholder="请输入用户名" - ) - .error-message#username-error - - .form-group - label.form-label(for="name") 昵称 - input.form-input#name( - type="text" - name="name" - value=user.name || '' - placeholder="请输入昵称" - ) - - .form-group - label.form-label(for="email") 邮箱 - input.form-input#email( - type="email" - name="email" - value=user.email || '' - placeholder="请输入邮箱地址" - ) - .error-message#email-error - - .form-group - label.form-label(for="bio") 个人简介 - textarea.form-textarea#bio( - name="bio" - placeholder="介绍一下自己..." - )= user.bio || '' - - .form-group - label.form-label(for="avatar") 头像URL - input.form-input#avatar( - type="url" - name="avatar" - value=user.avatar || '' - placeholder="请输入头像图片链接" - ) - - .form-actions - button.btn.btn-primary(type="submit") 保存更改 - button.btn.btn-secondary(type="button" onclick="resetForm()") 重置 - - // 账户安全标签页 - .tab-pane#security-tab - .profile-section - h2.section-title 账户安全 - - // 修改密码 - form#passwordForm(action="/profile/change-password", method="POST") - // 消息提示区域 - .message-container - .message.success#passwordMessage - span 密码修改成功! - button.message-close(type="button" onclick="closeMessage('passwordMessage')") × - .message.error#passwordError - span#passwordErrorMessage 密码修改失败,请重试 - button.message-close(type="button" onclick="closeMessage('passwordError')") × - - .form-group - label.form-label(for="oldPassword") 当前密码 * - input.form-input#oldPassword( - type="password" - name="oldPassword" - required - placeholder="请输入当前密码" - ) - - .form-group - label.form-label(for="newPassword") 新密码 * - input.form-input#newPassword( - type="password" - name="newPassword" - required - placeholder="请输入新密码(至少6位)" - minlength="6" - ) - - .form-group - label.form-label(for="confirmPassword") 确认新密码 * - input.form-input#confirmPassword( - type="password" - name="confirmPassword" - required - placeholder="请再次输入新密码" - minlength="6" - ) - - .form-actions - button.btn.btn-primary(type="submit") 修改密码 - button.btn.btn-secondary(type="button" onclick="resetPasswordForm()") 清空 - - // 账户信息 - .info-grid - .info-item - span.info-label 最后更新 - span.info-value #{user.updated_at ? new Date(user.updated_at).toLocaleDateString('zh-CN') : 'N/A'} - -block pageScripts - script(src="/js/profile.js") \ No newline at end of file diff --git a/src/views/page/register/index.pug b/src/views/page/register/index.pug deleted file mode 100644 index 1af0613..0000000 --- a/src/views/page/register/index.pug +++ /dev/null @@ -1,119 +0,0 @@ -extends /layouts/empty.pug - -block pageHead - style. - body { - background: #f5f7fa; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - } - .register-container { - max-width: 400px; - margin: 60px auto; - background: #fff; - border-radius: 10px; - box-shadow: 0 2px 16px rgba(0,0,0,0.08); - padding: 32px 28px 24px 28px; - } - .register-title { - text-align: center; - font-size: 2rem; - margin-bottom: 24px; - color: #333; - font-weight: 600; - } - .form-group { - margin-bottom: 18px; - } - label { - display: block; - margin-bottom: 6px; - color: #555; - font-size: 1rem; - } - input[type="text"], - input[type="email"], - input[type="password"] { - width: 100%; - padding: 10px 12px; - border: 1px solid #d1d5db; - border-radius: 6px; - font-size: 1rem; - background: #f9fafb; - transition: border 0.2s; - box-sizing: border-box; - } - input:focus { - border-color: #409eff; - outline: none; - } - .register-btn { - width: 100%; - padding: 12px 0; - background: linear-gradient(90deg, #409eff 0%, #66b1ff 100%); - color: #fff; - border: none; - border-radius: 6px; - font-size: 1.1rem; - font-weight: 600; - cursor: pointer; - margin-top: 10px; - transition: background 0.2s; - } - .register-btn:hover { - background: linear-gradient(90deg, #66b1ff 0%, #409eff 100%); - } - .login-link { - display: block; - text-align: right; - margin-top: 14px; - color: #409eff; - text-decoration: none; - font-size: 0.95rem; - } - .login-link:hover { - text-decoration: underline; - } - .captcha-container { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; - } - .captcha-container img { - width: 100px; - height: 30px; - border: 1px solid #d1d5db; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - } - .captcha-container img:hover { - border-color: #409eff; - box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1); - } - .captcha-container input { - flex: 1; - margin-bottom: 0; - } - -block pageContent - .register-container - .register-title 注册账号 - form(action="/register" method="post") - .form-group - label(for="username") 用户名 - input(type="text" id="username" name="username" required placeholder="请输入用户名") - .form-group - label(for="password") 密码 - input(type="password" id="password" name="password" required placeholder="请输入密码") - .form-group - label(for="confirm_password") 确认密码 - input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码") - .form-group - label(for="code") 验证码 - .captcha-container - img#captcha-img(src="/captcha", alt="验证码" title="点击刷新验证码") - input(type="text" id="code" name="code" required placeholder="请输入验证码") - script(src="/js/register.js") - button.register-btn(type="submit") 注册 - a.login-link(href="/login") 已有账号?去登录 \ No newline at end of file