From ce9effed4282c6e00afd6eea2d91e39bb774846d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Sat, 30 Aug 2025 14:56:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=96=87=E6=A1=A3=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E5=92=8C=E4=B9=A6=E7=AD=BE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=92=8C=E4=B9=A6=E7=AD=BE=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=81=94=E7=B3=BB=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=92=8C=E5=B8=AE=E5=8A=A9=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=8C=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/development.sqlite3 | Bin 4096 -> 73728 bytes database/development.sqlite3-shm | Bin 32768 -> 32768 bytes database/development.sqlite3-wal | Bin 341992 -> 12392 bytes public/css/layouts/empty.css | 14 + src/controllers/Page/PageController.js | 49 ++- 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 ++++++++ .../20250827090000_create_bookmarks_table.mjs | 28 -- .../20250830014825_create_articles_table.mjs | 26 ++ .../20250830015422_create_bookmarks_table.mjs | 25 ++ .../20250830020000_add_article_fields.mjs | 60 +++ src/db/models/ArticleModel.js | 298 +++++++++++++++ src/db/seeds/20250830020000_articles_seed.mjs | 77 ++++ src/services/ArticleService.js | 295 +++++++++++++++ src/services/BookmarkService.js | 312 ++++++++++++++++ src/services/README.md | 222 +++++++++++ src/services/SiteConfigService.js | 292 ++++++++++++++- src/services/index.js | 36 ++ src/services/userService.js | 415 +++++++++++++++++++-- src/views/htmx/footer.pug | 2 + src/views/layouts/empty.pug | 3 + src/views/page/extra/contact.pug | 83 +++++ src/views/page/extra/help.pug | 97 +++++ src/views/page/index/index.pug | 16 +- src/views/page/notice/index.pug | 7 + 28 files changed, 3301 insertions(+), 96 deletions(-) create mode 100644 src/db/docs/ArticleModel.md create mode 100644 src/db/docs/BookmarkModel.md create mode 100644 src/db/docs/README.md create mode 100644 src/db/docs/SiteConfigModel.md create mode 100644 src/db/docs/UserModel.md delete mode 100644 src/db/migrations/20250827090000_create_bookmarks_table.mjs create mode 100644 src/db/migrations/20250830014825_create_articles_table.mjs create mode 100644 src/db/migrations/20250830015422_create_bookmarks_table.mjs create mode 100644 src/db/migrations/20250830020000_add_article_fields.mjs create mode 100644 src/db/models/ArticleModel.js create mode 100644 src/db/seeds/20250830020000_articles_seed.mjs create mode 100644 src/services/ArticleService.js create mode 100644 src/services/BookmarkService.js create mode 100644 src/services/README.md create mode 100644 src/services/index.js create mode 100644 src/views/page/extra/contact.pug create mode 100644 src/views/page/extra/help.pug create mode 100644 src/views/page/notice/index.pug diff --git a/database/development.sqlite3 b/database/development.sqlite3 index 3f03792bc7042f2308a00071280fc50a710f3be0..6aaf18d88845533189e22a0f7a276756f1038016 100644 GIT binary patch literal 73728 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 delta 26 gcmZoTz|x>FL7J73fq{W>;zWBOL$7GjCWkrv09oY*-2eap diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index e623578f1cd1180d3e5edbce7a95d2cf48b96560..305e83574bf3f481d06a0183ad4fc762bfcf37f0 100644 GIT binary patch delta 172 zcmZo@U}|V!s+V}A%K!q55G=q9q=kUEi2E8t-I~MK{E|h=LN@KcYo#~m$_AC4cS%(b uGzttr=KeL*z4<7krQ_xYj5h3yjGHeq{$^!l0#bjO7@2_-w;lj1hcqDo literal 32768 zcmeI)Nm3L+6oBC$kXf)rltu&vWN<(RWs*TX+Jhwfm93JB}0}Dy6EOP zuX)EL@0nwP&wNdFXC7XGn*^G;PdiU|N)LSuG0qe-%(KV_@t#J90_7{vObZX`=LK&V zurjp7A_Q~?8v}z7gAmMx4A;1et{q-ex4fc> GQ2+q11Y9)$ literal 341992 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 UWpt#I0i$cAF9!Oer!U+7KM{;h5&!@I diff --git a/public/css/layouts/empty.css b/public/css/layouts/empty.css index 5776eb5..3ccdb48 100644 --- a/public/css/layouts/empty.css +++ b/public/css/layouts/empty.css @@ -429,4 +429,18 @@ body { mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; +} + +.fe--notice-active { + display: inline-block; + width: 24px; + height: 24px; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M15.085 4.853a2.501 2.501 0 1 1 2.572 3.142A6 6 0 0 1 18 10v6h1c.55 0 1 .45 1 1s-.45 1-1 1h-4v1a3 3 0 0 1-6 0v-1H5c-.55 0-1-.45-1-1s.45-1 1-1h1v-6a6 6 0 0 1 5-5.917V3a1 1 0 0 1 2 0v1.083a6 6 0 0 1 2.085.77M12 20a1 1 0 0 0 1-1v-1h-2v1a1 1 0 0 0 1 1m-4-4h8v-6a4 4 0 1 0-8 0z'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; } \ No newline at end of file diff --git a/src/controllers/Page/PageController.js b/src/controllers/Page/PageController.js index b667d1f..3deeda6 100644 --- a/src/controllers/Page/PageController.js +++ b/src/controllers/Page/PageController.js @@ -1,17 +1,22 @@ import Router from "utils/router.js" import UserService from "services/UserService.js" import SiteConfigService from "services/SiteConfigService.js" +import ArticleService from "services/ArticleService.js" import svgCaptcha from "svg-captcha" import CommonError from "@/utils/error/CommonError" +import { logger } from "@/logger.js" class PageController { constructor() { this.userService = new UserService() this.siteConfigService = new SiteConfigService() + this.siteConfigService = new SiteConfigService() + this.articleService = new ArticleService() } // 首页 async indexGet(ctx) { + const blogs = await this.articleService.getAllArticles() return await ctx.render( "page/index/index", { @@ -22,24 +27,7 @@ class PageController { url: "https://pic.xieyaxin.top/random.php", }, ], - blogs: [ - { - title: "以梦为马,不负韶华", - content: "生活如诗,岁月如歌。让我们在时光的长河中,保持热爱,奔赴山海,书写属于自己的精彩篇章。", - }, - { - title: "风雨过后是彩虹", - content: "以前,我以为自己很坚强,直到有一天,我发现自己连哭都哭不出来。", - }, - { - title: "岁月如歌", - content: "未来,我希望能有更多的时间陪伴家人,有更多的时间去旅行,有更多的时间去实现自己的梦想。", - }, - { - title: "明月如水", - content: "啊啊" - } - ].slice(0, 4), + blogs: blogs.slice(0, 4) }, { includeSite: true, includeUser: true } ) @@ -146,6 +134,27 @@ class PageController { ctx.set("hx-redirect", "/") } + // 处理联系表单提交 + 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: "感谢您的留言,我们会尽快回复您!" + } + } + // 渲染页面 pageGet(name, data) { return async ctx => { @@ -176,6 +185,10 @@ class PageController { router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false }) router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false }) router.get("/profile", controller.pageGet("page/profile/index"), { auth: true }) + router.get("/notice", controller.pageGet("page/notice/index"), { auth: true }) + router.get("/help", controller.pageGet("page/extra/help"), { auth: false }) + router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false }) + router.post("/contact", controller.contactPost.bind(controller), { auth: false }) router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) router.post("/login", controller.loginPost.bind(controller), { auth: false }) router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) diff --git a/src/db/docs/ArticleModel.md b/src/db/docs/ArticleModel.md new file mode 100644 index 0000000..c7e3d93 --- /dev/null +++ b/src/db/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/db/docs/BookmarkModel.md b/src/db/docs/BookmarkModel.md new file mode 100644 index 0000000..273129b --- /dev/null +++ b/src/db/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/db/docs/README.md b/src/db/docs/README.md new file mode 100644 index 0000000..16a5aec --- /dev/null +++ b/src/db/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/db/docs/SiteConfigModel.md b/src/db/docs/SiteConfigModel.md new file mode 100644 index 0000000..64b03d5 --- /dev/null +++ b/src/db/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/db/docs/UserModel.md b/src/db/docs/UserModel.md new file mode 100644 index 0000000..c8bb373 --- /dev/null +++ b/src/db/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/db/migrations/20250827090000_create_bookmarks_table.mjs b/src/db/migrations/20250827090000_create_bookmarks_table.mjs deleted file mode 100644 index b781d5c..0000000 --- a/src/db/migrations/20250827090000_create_bookmarks_table.mjs +++ /dev/null @@ -1,28 +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/20250830014825_create_articles_table.mjs b/src/db/migrations/20250830014825_create_articles_table.mjs new file mode 100644 index 0000000..7dcf1b9 --- /dev/null +++ b/src/db/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/db/migrations/20250830015422_create_bookmarks_table.mjs b/src/db/migrations/20250830015422_create_bookmarks_table.mjs new file mode 100644 index 0000000..52ff3cc --- /dev/null +++ b/src/db/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/db/migrations/20250830020000_add_article_fields.mjs b/src/db/migrations/20250830020000_add_article_fields.mjs new file mode 100644 index 0000000..2775c57 --- /dev/null +++ b/src/db/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/db/models/ArticleModel.js b/src/db/models/ArticleModel.js new file mode 100644 index 0000000..4b83535 --- /dev/null +++ b/src/db/models/ArticleModel.js @@ -0,0 +1,298 @@ +import db from "../index.js" + +class ArticleModel { + static async findAll() { + return db("articles").orderBy("created_at", "desc") + } + + static async findPublished() { + return db("articles") + .where("status", "published") + .whereNotNull("published_at") + .orderBy("published_at", "desc") + } + + 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/seeds/20250830020000_articles_seed.mjs b/src/db/seeds/20250830020000_articles_seed.mjs new file mode 100644 index 0000000..0dea864 --- /dev/null +++ b/src/db/seeds/20250830020000_articles_seed.mjs @@ -0,0 +1,77 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const seed = async knex => { + // 清空表 + await knex("articles").del() + + // 插入示例数据 + await knex("articles").insert([ + { + title: "欢迎使用文章管理系统", + content: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理。系统提供了丰富的功能,包括标签管理、分类管理、SEO优化等。\n\n## 主要特性\n\n- 支持Markdown格式\n- 标签和分类管理\n- SEO优化\n- 阅读时间计算\n- 浏览量统计\n- 草稿和发布状态管理", + author: "系统管理员", + category: "系统介绍", + tags: "系统, 介绍, 功能", + keywords: "文章管理, 系统介绍, 功能特性", + description: "介绍文章管理系统的主要功能和特性", + status: "published", + published_at: knex.fn.now(), + excerpt: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理...", + reading_time: 3, + slug: "welcome-to-article-management-system", + meta_title: "欢迎使用文章管理系统 - 功能特性介绍", + meta_description: "了解文章管理系统的主要功能,包括Markdown支持、标签管理、SEO优化等特性" + }, + { + title: "Markdown 写作指南", + content: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档。\n\n## 基本语法\n\n### 标题\n使用 `#` 符号创建标题:\n\n```markdown\n# 一级标题\n## 二级标题\n### 三级标题\n```\n\n### 列表\n- 无序列表使用 `-` 或 `*`\n- 有序列表使用数字\n\n### 链接和图片\n[链接文本](URL)\n![图片描述](图片URL)\n\n### 代码\n使用反引号标记行内代码:`code`\n\n使用代码块:\n```javascript\nfunction hello() {\n console.log('Hello World!');\n}\n```", + author: "技术编辑", + category: "写作指南", + tags: "Markdown, 写作, 指南", + keywords: "Markdown, 写作指南, 语法, 教程", + description: "详细介绍Markdown的基本语法和用法,帮助用户快速掌握Markdown写作", + status: "published", + published_at: knex.fn.now(), + excerpt: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档...", + reading_time: 8, + slug: "markdown-writing-guide", + meta_title: "Markdown 写作指南 - 从入门到精通", + meta_description: "学习Markdown的基本语法,包括标题、列表、链接、图片、代码等常用元素的写法" + }, + { + title: "SEO 优化最佳实践", + content: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。\n\n## 关键词研究\n\n关键词研究是SEO的基础,需要:\n- 了解目标受众的搜索习惯\n- 分析竞争对手的关键词\n- 选择合适的关键词密度\n\n## 内容优化\n\n### 标题优化\n- 标题应包含主要关键词\n- 标题长度控制在50-60字符\n- 使用吸引人的标题\n\n### 内容结构\n- 使用H1-H6标签组织内容\n- 段落要简洁明了\n- 添加相关图片和视频\n\n## 技术SEO\n\n- 确保网站加载速度快\n- 优化移动端体验\n- 使用结构化数据\n- 建立内部链接结构", + author: "SEO专家", + category: "数字营销", + tags: "SEO, 优化, 搜索引擎, 营销", + keywords: "SEO优化, 搜索引擎优化, 关键词研究, 内容优化", + description: "介绍SEO优化的最佳实践,包括关键词研究、内容优化和技术SEO等方面", + status: "published", + published_at: knex.fn.now(), + excerpt: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。本文介绍SEO优化的最佳实践...", + reading_time: 12, + slug: "seo-optimization-best-practices", + meta_title: "SEO 优化最佳实践 - 提升网站排名", + meta_description: "学习SEO优化的关键技巧,包括关键词研究、内容优化和技术SEO,帮助提升网站在搜索引擎中的排名" + }, + { + title: "前端开发趋势 2024", + content: "2024年前端开发领域出现了许多新的趋势和技术。\n\n## 主要趋势\n\n### 1. 框架发展\n- React 18的新特性\n- Vue 3的Composition API\n- Svelte的崛起\n\n### 2. 构建工具\n- Vite的快速构建\n- Webpack 5的模块联邦\n- Turbopack的性能提升\n\n### 3. 性能优化\n- 核心Web指标\n- 图片优化\n- 代码分割\n\n### 4. 新特性\n- CSS容器查询\n- CSS Grid布局\n- Web Components\n\n## 学习建议\n\n建议开发者关注这些趋势,但不要盲目追新,要根据项目需求选择合适的技术栈。", + author: "前端开发者", + category: "技术趋势", + tags: "前端, 开发, 趋势, 2024", + keywords: "前端开发, 技术趋势, React, Vue, 性能优化", + description: "分析2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等方面", + status: "draft", + excerpt: "2024年前端开发领域出现了许多新的趋势和技术。本文分析主要趋势并提供学习建议...", + reading_time: 10, + slug: "frontend-development-trends-2024", + meta_title: "前端开发趋势 2024 - 技术发展分析", + meta_description: "了解2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等,为技术选型提供参考" + } + ]) + + console.log("✅ Articles seeded successfully!") +} diff --git a/src/services/ArticleService.js b/src/services/ArticleService.js new file mode 100644 index 0000000..1364348 --- /dev/null +++ b/src/services/ArticleService.js @@ -0,0 +1,295 @@ +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 new file mode 100644 index 0000000..249591c --- /dev/null +++ b/src/services/BookmarkService.js @@ -0,0 +1,312 @@ +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/README.md b/src/services/README.md new file mode 100644 index 0000000..a9b4f8f --- /dev/null +++ b/src/services/README.md @@ -0,0 +1,222 @@ +# 服务层 (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 index 17c0239..59537fd 100644 --- a/src/services/SiteConfigService.js +++ b/src/services/SiteConfigService.js @@ -1,25 +1,299 @@ import SiteConfigModel from "../db/models/SiteConfigModel.js" +import CommonError from "utils/error/CommonError.js" class SiteConfigService { - // 获取单个配置 + // 获取指定key的配置 async get(key) { - return await SiteConfigModel.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) { - return await SiteConfigModel.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) { - return await SiteConfigModel.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() { - return await SiteConfigModel.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 \ No newline at end of file +export default SiteConfigService +export { SiteConfigService } \ No newline at end of file diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 0000000..db42d64 --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,36 @@ +// 服务层统一导出 +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 index d6065ce..edd9981 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -1,72 +1,413 @@ import UserModel from "db/models/UserModel.js" import { hashPassword, comparePassword } from "utils/bcrypt.js" -import CommonError from "utils/error/CommonError" +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) { - // 这里可以调用数据库模型 - // 示例返回 - return { id, name: `User_${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() { - return await UserModel.findAll() + 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) { - if (!data.name) throw new Error("用户名不能为空") - return await UserModel.create(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) { - const user = await UserModel.findById(id) - if (!user) throw new Error("用户不存在") - return await UserModel.update(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) { - const user = await UserModel.findById(id) - if (!user) throw new Error("用户不存在") - return await UserModel.delete(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) { - if (!data.username || !data.password) throw new CommonError("用户名、邮箱和密码不能为空") - const existUser = await UserModel.findByUsername(data.username) - if (existUser) throw new CommonError(`用户名${data.username}已存在`) - // 密码加密 - 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 + 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 }) { - let user - if (username) { - user = await UserModel.findByUsername(username) - } else if (email) { - user = await UserModel.findByEmail(email) + 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}`) } - if (!user) throw new Error("用户不存在") - // 校验密码 - const ok = await comparePassword(password, user.password) - if (!ok) throw new Error("密码错误") - // 生成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 } } } diff --git a/src/views/htmx/footer.pug b/src/views/htmx/footer.pug index 4dc54b1..42f27b3 100644 --- a/src/views/htmx/footer.pug +++ b/src/views/htmx/footer.pug @@ -7,6 +7,8 @@ a(href="/") 首页 li a(href="/about") 关于我们 + li + a(href="/contact") 联系我们 style. .footer-panel { background: rgba(34,34,34,.25); diff --git a/src/views/layouts/empty.pug b/src/views/layouts/empty.pug index 5bacb35..78d79f6 100644 --- a/src/views/layouts/empty.pug +++ b/src/views/layouts/empty.pug @@ -25,6 +25,8 @@ block $$content .right.menu.desktop-only a.menu-item(hx-post="/logout") 退出 a.menu-item(href="/profile") 欢迎您 , #{$user.username} + a.menu-item(href="/notice") + .fe--notice-active // 移动端:汉堡按钮 button.menu-toggle(type="button" aria-label="打开菜单") span.bar @@ -43,6 +45,7 @@ block $$content .right.menu a.menu-item(hx-post="/logout") 退出 a.menu-item() 欢迎您 , #{$user.username} + a.menu-item(href="/notice" class="fe--notice-active") 公告 .page-layout .page.container block pageContent diff --git a/src/views/page/extra/contact.pug b/src/views/page/extra/contact.pug new file mode 100644 index 0000000..f334074 --- /dev/null +++ b/src/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/views/page/extra/help.pug b/src/views/page/extra/help.pug new file mode 100644 index 0000000..84a8d5d --- /dev/null +++ b/src/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/views/page/index/index.pug b/src/views/page/index/index.pug index 72463e7..4224ea3 100644 --- a/src/views/page/index/index.pug +++ b/src/views/page/index/index.pug @@ -15,13 +15,21 @@ 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") a(href="/article/1" class="hover:text-blue-600 transition-colors duration-200") #{blog.title} - p.article-meta(class="text-sm text-gray-400 mb-3") - | 作者:明月  |  2024-06-01  |  分类:生活感悟 + p.article-meta(class="text-sm text-gray-400 mb-3 flex") + span(class="mr-2 line-clamp-1" title=blog.author) + span 作者: + a(href=blog.author class="hover:text-blue-600 transition-colors duration-200") #{blog.author} + span(class="mr-2 whitespace-nowrap") + span | + a(href=blog.updated_at.slice(0, 10) class="hover:text-blue-600 transition-colors duration-200") #{blog.updated_at.slice(0, 10)} + span(class="mr-2 whitespace-nowrap") + span | 分类: + a(href=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.content} + | #{blog.description} a(href="/article/1" class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → mixin empty() @@ -44,7 +52,7 @@ block pageContent each blog in blogs +card(blog) else - +empty() 空 + +empty() 文章数据为空 div(class="mt-[20px]") h2(class="text-[20px] font-bold mb-[10px]") 收藏列表 if collections && collections.length > 0 diff --git a/src/views/page/notice/index.pug b/src/views/page/notice/index.pug new file mode 100644 index 0000000..ae96700 --- /dev/null +++ b/src/views/page/notice/index.pug @@ -0,0 +1,7 @@ +extends /layouts/empty.pug + +block pageHead + + +block pageContent + div 这里是通知界面 \ No newline at end of file