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\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\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