From 4f34ac698853b0191bbeb47eefae969a9f24f36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Fri, 12 Sep 2025 16:47:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E5=8F=8A=E8=B7=AF=E7=94=B1=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 jsconfig.json,更新模块和目标版本为 ESNext 和 ES2020,增加模块解析和类型检查选项 - 在 knexfile.mjs 中优化 SQLite 性能设置,确保连接创建后只调用一次 done() - 更新公共样式,增强响应式设计,改善用户界面体验 - 在 logger.js 中移除错误日志记录,简化日志配置 - 在 main.js 中重构插件注册逻辑,确保中间件异步加载 - 在 BaseController.js 中新增用户登录状态检查和获取用户ID的方法 - 在 JobController.js 中为路由添加认证中间件 - 在 CommonController.js 中提供全局数据,优化首页渲染逻辑 - 在 install.js 中增强中间件功能,提供全局配置数据 - 在 Auth 中间件中优化用户验证逻辑,确保状态管理一致性 - 在 errorHandler 中增强错误响应格式,提升开发环境调试体验 - 更新路由和视图文件,确保数据传递和渲染逻辑一致性 --- bun.lockb | Bin 185199 -> 190837 bytes jsconfig.json | 10 +- knexfile.mjs | 28 +- package.json | 2 + public/css/page/index.css | 213 ++++++--- public/images/dashboard-bg.svg | 3 + public/images/hero-bg.svg | 3 + public/images/stats-bg.svg | 3 + src/base/BaseController.js | 22 +- src/controllers/Api/JobController.js | 3 +- src/controllers/Page/CommonController.js | 38 +- src/db/index.js | 176 ++++---- src/db/models/SiteConfigModel.js | 2 +- src/logger.js | 5 +- src/main.js | 57 +-- src/middlewares/Auth/index.js | 8 +- src/middlewares/Views/index.js | 31 +- src/middlewares/errorHandler/index.js | 93 +++- src/middlewares/install.js | 42 +- src/services/ArticleService.js | 563 +++++++++++++++++++++++ src/services/BookmarkService.js | 492 ++++++++++++++++++++ src/services/ContactService.js | 511 +++++++++++++++++++++ src/services/SiteConfigService.js | 513 +++++++++++++++++++++ src/services/UserService.js | 415 +++++++++++++++++ src/services/index.js | 84 ++++ src/utils/ForRegister.js | 13 +- src/utils/error/AuthError.js | 10 + src/utils/error/CommonError.js | 4 +- src/utils/router.js | 40 +- src/utils/router/RouteAuth.js | 48 +- src/views/htmx/footer.pug | 2 +- src/views/layouts/empty.pug | 21 +- 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/index copy/index.pug | 10 - src/views/page/index/index copy 2.pug | 11 - src/views/page/index/index copy 3.pug | 69 --- src/views/page/index/index copy.pug | 31 +- src/views/page/index/index.pug | 20 +- src/views/page/index/index.scss | 146 ++++++ src/views/page/login/index.pug | 19 - src/views/page/notice/index.pug | 7 - src/views/page/profile/index.pug | 752 ------------------------------- src/views/page/register/index.pug | 119 ----- 48 files changed, 3321 insertions(+), 1637 deletions(-) create mode 100644 public/images/dashboard-bg.svg create mode 100644 public/images/hero-bg.svg create mode 100644 public/images/stats-bg.svg create mode 100644 src/services/ArticleService.js create mode 100644 src/services/BookmarkService.js create mode 100644 src/services/ContactService.js create mode 100644 src/services/SiteConfigService.js create mode 100644 src/services/UserService.js create mode 100644 src/services/index.js create mode 100644 src/utils/error/AuthError.js 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/index copy/index.pug delete mode 100644 src/views/page/index/index copy 2.pug delete mode 100644 src/views/page/index/index copy 3.pug create mode 100644 src/views/page/index/index.scss 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/bun.lockb b/bun.lockb index 3adb2190a26602ee325691a632f3936a468ce1ee..3882124f5e3b9c847ab429da73524e2c801c49ae 100644 GIT binary patch delta 32366 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 delta 29094 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 diff --git a/jsconfig.json b/jsconfig.json index 46359a5..8b7bdc7 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -18,9 +18,13 @@ "src/services/*" ] }, - "module": "commonjs", - "target": "es6", - "allowSyntheticDefaultImports": true + "module": "ESNext", + "target": "ES2020", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "allowJs": true, + "checkJs": false }, "include": [ "src/**/*", diff --git a/knexfile.mjs b/knexfile.mjs index f30202e..e90a593 100644 --- a/knexfile.mjs +++ b/knexfile.mjs @@ -30,12 +30,13 @@ export default { createRetryIntervalMillis: 200, // 创建连接重试间隔 afterCreate: (conn, done) => { // SQLite 性能优化设置 - conn.run("PRAGMA journal_mode = WAL", done) // 启用 WAL 模式提高并发 - conn.run("PRAGMA synchronous = NORMAL", done) // 平衡性能和安全性 - conn.run("PRAGMA cache_size = 1000", done) // 增加缓存大小 - conn.run("PRAGMA temp_store = MEMORY", done) // 临时数据存储在内存中 - conn.run("PRAGMA mmap_size = 67108864", done) // 启用内存映射,64MB - conn.run("PRAGMA foreign_keys = ON", done) // 启用外键约束 + conn.run("PRAGMA journal_mode = WAL") + conn.run("PRAGMA synchronous = NORMAL") + conn.run("PRAGMA cache_size = 1000") + conn.run("PRAGMA temp_store = MEMORY") + conn.run("PRAGMA mmap_size = 67108864") + conn.run("PRAGMA foreign_keys = ON") + done() // 只调用一次 done() }, }, }, @@ -71,13 +72,14 @@ export default { createRetryIntervalMillis: 200, afterCreate: (conn, done) => { // SQLite 性能优化设置 - conn.run("PRAGMA journal_mode = WAL", done) - conn.run("PRAGMA synchronous = NORMAL", done) - conn.run("PRAGMA cache_size = 2000", done) // 生产环境更大缓存 - conn.run("PRAGMA temp_store = MEMORY", done) - conn.run("PRAGMA mmap_size = 134217728", done) // 128MB 内存映射 - conn.run("PRAGMA foreign_keys = ON", done) - conn.run("PRAGMA auto_vacuum = INCREMENTAL", done) // 增量清理 + conn.run("PRAGMA journal_mode = WAL") + conn.run("PRAGMA synchronous = NORMAL") + conn.run("PRAGMA cache_size = 2000") // 生产环境更大缓存 + conn.run("PRAGMA temp_store = MEMORY") + conn.run("PRAGMA mmap_size = 134217728") // 128MB 内存映射 + conn.run("PRAGMA foreign_keys = ON") + conn.run("PRAGMA auto_vacuum = INCREMENTAL") // 增量清理 + done() // 只调用一次 done() }, }, }, diff --git a/package.json b/package.json index dbdf18b..c25f9e6 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "get-paths": "^0.0.7", "image-thumbnail": "^1.0.17", "jsonwebtoken": "^9.0.0", + "jstransformer-markdown-it": "^3.0.0", + "jstransformer-scss": "^2.0.0", "knex": "^3.1.0", "koa": "^3.0.0", "koa-bodyparser": "^4.4.1", diff --git a/public/css/page/index.css b/public/css/page/index.css index 77f4e86..7a6483a 100644 --- a/public/css/page/index.css +++ b/public/css/page/index.css @@ -1,69 +1,146 @@ -.list { - display: flex; - gap: 15px; - flex-wrap: wrap; - - &.blog { - - >* { - width: calc(25% - 15px * 3 / 4); - } - - /* ≥1024px 默认4列;介于768px-1023px 显示3列 */ - @media (max-width: 1023px) { - >* { - width: calc(33.3333% - 15px * 2 / 3); - } - } - - /* 介于640px-767px 显示2列 */ - @media (max-width: 767px) { - >* { - width: calc(50% - 15px * 1 / 2); - } - } - - /* <640px 显示1列,并优化间距与字号 */ - @media (max-width: 639px) { - gap: 12px; - - >* { - width: 100%; - } - - .article-card { - padding: 14px; - } - - .article-title { - font-size: 16px; - } - - .article-meta { - font-size: 12px; - } - - .article-desc { - font-size: 14px; - } - } - } -} - -.list a:hover { - text-decoration: underline; -} - -.material-symbols-light--info-rounded { - 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' d='M12 16.5q.214 0 .357-.144T12.5 16v-4.5q0-.213-.144-.356T11.999 11t-.356.144t-.143.356V16q0 .213.144.356t.357.144M12 9.577q.262 0 .439-.177t.176-.438t-.177-.439T12 8.346t-.438.177t-.177.439t.177.438t.438.177M12.003 21q-1.867 0-3.51-.708q-1.643-.709-2.859-1.924t-1.925-2.856T3 12.003t.709-3.51Q4.417 6.85 5.63 5.634t2.857-1.925T11.997 3t3.51.709q1.643.708 2.859 1.922t1.925 2.857t.709 3.509t-.708 3.51t-1.924 2.859t-2.856 1.925t-3.509.709'/%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%; +/* 首页样式 */ + +.hero-section { + position: relative; + overflow: hidden; +} + +.hero-section::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/hero-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.1; + z-index: 0; +} + +.hero-content { + position: relative; + z-index: 1; +} + +.feature-card { + transition: all 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); +} + +.feature-card .material-symbols-light--article, +.feature-card .material-symbols-light--bookmark, +.feature-card .material-symbols-light--person { + transition: all 0.3s ease; +} + +.feature-card:hover .material-symbols-light--article, +.feature-card:hover .material-symbols-light--bookmark, +.feature-card:hover .material-symbols-light--person { + transform: scale(1.1); +} + +.stats-section { + position: relative; +} + +.stats-section::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/stats-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.05; + z-index: 0; +} + +.stat-item { + transition: all 0.3s ease; +} + +.stat-item:hover { + transform: scale(1.05); +} + +.user-dashboard { + position: relative; +} + +.user-dashboard::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/dashboard-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.03; + z-index: 0; +} + +.avatar { + transition: all 0.3s ease; +} + +.avatar:hover { + transform: scale(1.05); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .hero-section { + padding: 4rem 0; + } + + .hero-content h1 { + font-size: 2.5rem; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .user-info { + text-align: center; + margin-bottom: 1.5rem; + } + + .user-actions { + justify-content: center; + } +} + +@media (max-width: 480px) { + .hero-content h1 { + font-size: 2rem; + } + + .hero-content p { + font-size: 1rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .hero-actions { + flex-direction: column; + gap: 1rem; + } + + .hero-actions a { + width: 100%; + text-align: center; + } } \ No newline at end of file diff --git a/public/images/dashboard-bg.svg b/public/images/dashboard-bg.svg new file mode 100644 index 0000000..f21bff1 --- /dev/null +++ b/public/images/dashboard-bg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/hero-bg.svg b/public/images/hero-bg.svg new file mode 100644 index 0000000..5fe0eaf --- /dev/null +++ b/public/images/hero-bg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/stats-bg.svg b/public/images/stats-bg.svg new file mode 100644 index 0000000..ec590c6 --- /dev/null +++ b/public/images/stats-bg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/base/BaseController.js b/src/base/BaseController.js index 853fa86..a464c97 100644 --- a/src/base/BaseController.js +++ b/src/base/BaseController.js @@ -255,6 +255,25 @@ class BaseController { } /** + * 检查用户是否已登录 + * @param {*} ctx - Koa上下文 + * @returns {boolean} 是否已登录 + */ + isLoggedIn(ctx) { + return !!ctx.state.user + } + + /** + * 获取用户ID + * @param {*} ctx - Koa上下文 + * @returns {string|number|null} 用户ID + */ + getCurrentUserId(ctx) { + const user = this.getCurrentUser(ctx) + return user ? (user.id || user._id || null) : null + } + + /** * 检查用户权限 * @param {*} ctx - Koa上下文 * @param {string|Array} permission - 权限名或权限数组 @@ -286,7 +305,8 @@ class BaseController { throw new CommonError("用户未登录") } - if (resource[ownerField] !== user.id && resource[ownerField] !== user.username) { + const userId = this.getCurrentUserId(ctx) + if (resource[ownerField] !== userId && resource[ownerField] !== user.username) { throw new CommonError("无权限操作此资源") } } diff --git a/src/controllers/Api/JobController.js b/src/controllers/Api/JobController.js index 1f9cf6d..a691f3c 100644 --- a/src/controllers/Api/JobController.js +++ b/src/controllers/Api/JobController.js @@ -34,7 +34,8 @@ class JobController { static createRoutes() { const controller = new JobController() - const router = new Router({ prefix: "/api/jobs" }) + const router = new Router({ prefix: "/api/jobs", auth: true }) + router.get("/", controller.list.bind(controller)) router.get("/", controller.list.bind(controller)) router.post("/start/:id", controller.start.bind(controller)) router.post("/stop/:id", controller.stop.bind(controller)) diff --git a/src/controllers/Page/CommonController.js b/src/controllers/Page/CommonController.js index 118b693..a9e77a2 100644 --- a/src/controllers/Page/CommonController.js +++ b/src/controllers/Page/CommonController.js @@ -1,17 +1,39 @@ import Router from "utils/router.js" import { logger } from "@/logger.js" import BaseController from "@/base/BaseController.js" - +import SiteConfigModel from '@/db/models/SiteConfigModel.js' export default class CommonController extends BaseController { constructor() { super() } + pageGet(...args) { + return (ctx) => { + return ctx.render(...args) + } + } + // 首页 async indexGet(ctx) { + // 可以在这里添加一些需要用户信息的逻辑 + // 例如获取用户相关的统计数据等 + const user = ctx.state.user || null; + + // 示例数据,实际项目中可以从数据库获取 + const stats = { + articles: 1234, + users: 567, + categories: 89, + responseTime: "24h" + }; + return await ctx.render( - "page/index/index", {}, { includeSite: true, includeUser: true } + "page/index/index", + { + stats, + // 其他需要传递给模板的数据 + } ) } @@ -24,8 +46,16 @@ export default class CommonController extends BaseController { const router = new Router({ auth: "try" }) // 首页 - router.get("", controller.handleRequest(controller.indexGet), { auth: false }) - router.get("/", controller.handleRequest(controller.indexGet), { auth: false }) + router.get("", controller.handleRequest(controller.indexGet)) + router.get("/", controller.handleRequest(controller.indexGet)) + // router.get("/about", controller.handleRequest(controller.pageGet("page/about/index"))) + router.get("/contact", controller.handleRequest(controller.pageGet("page/extra/contact"))) + router.get("/faq", controller.handleRequest(controller.pageGet("page/extra/faq"))) + router.get("/feedback", controller.handleRequest(controller.pageGet("page/extra/feedback"))) + router.get("/help", controller.handleRequest(controller.pageGet("page/extra/help"))) + router.get("/privacy", controller.handleRequest(controller.pageGet("page/extra/privacy"))) + router.get("/terms", controller.handleRequest(controller.pageGet("page/extra/terms"))) + router.get("/no-auth", controller.handleRequest(controller.pageGet("page/auth/no-auth"))) return router } diff --git a/src/db/index.js b/src/db/index.js index bf78b42..9a23f47 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -122,108 +122,98 @@ export const DbQueryCache = { // 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 -}) +if (buildKnex.QueryBuilder && typeof buildKnex.QueryBuilder.extend === 'function') { + 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 + }) -// 5) cacheInvalidate(): 使当前查询 key 的缓存失效 -buildKnex.QueryBuilder.extend("cacheInvalidate", function () { - const key = getCacheKeyForBuilder(this) - queryCache.delete(key) - return this -}) + // 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 + }) -// 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 -}) + // 5) cacheInvalidate(): 使当前查询 key 的缓存失效 + buildKnex.QueryBuilder.extend("cacheInvalidate", function () { + const key = getCacheKeyForBuilder(this) + queryCache.delete(key) + return this + }) -// 7) 数据变更时自动清理相关缓存 -buildKnex.QueryBuilder.extend("invalidateCache", function() { - const tableName = this._single?.table - if (tableName) { - DbQueryCache.invalidateByTable(tableName) - logger.debug(`清理表 ${tableName} 的缓存`) - } - 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 + }) -// 8) 为 CUD 操作添加自动缓存失效 -const originalInsert = buildKnex.QueryBuilder.prototype.insert -buildKnex.QueryBuilder.prototype.insert = function(...args) { - const tableName = this._single?.table - const result = originalInsert.apply(this, args) - if (tableName) { - // 在操作完成后清理缓存 - result.then(() => { + // 7) 数据变更时自动清理相关缓存 + buildKnex.QueryBuilder.extend("invalidateCache", function() { + const tableName = this._single?.table + if (tableName) { DbQueryCache.invalidateByTable(tableName) - }).catch(() => { - // 即使失败也清理缓存,保证一致性 - DbQueryCache.invalidateByTable(tableName) - }) - } - return result + logger.debug(`清理表 ${tableName} 的缓存`) + } + return this + }) } -const originalUpdate = buildKnex.QueryBuilder.prototype.update -buildKnex.QueryBuilder.prototype.update = function(...args) { - const tableName = this._single?.table - const result = originalUpdate.apply(this, args) - if (tableName) { - result.then(() => { - DbQueryCache.invalidateByTable(tableName) - }).catch(() => { - DbQueryCache.invalidateByTable(tableName) - }) +// 8) 为 CUD 操作添加自动缓存失效 +// 使用更安全的方式扩展 QueryBuilder 方法 +const addCacheInvalidation = (methodName) => { + if (buildKnex.QueryBuilder && buildKnex.QueryBuilder.prototype && buildKnex.QueryBuilder.prototype[methodName]) { + const originalMethod = buildKnex.QueryBuilder.prototype[methodName]; + buildKnex.QueryBuilder.prototype[methodName] = function(...args) { + const result = originalMethod.apply(this, args); + const tableName = this._single?.table; + + if (tableName && result && typeof result.then === 'function') { + // 在操作完成后清理缓存 + const originalThen = result.then; + result.then = function(...thenArgs) { + const promise = originalThen.apply(this, thenArgs); + promise.then(() => { + DbQueryCache.invalidateByTable(tableName); + }).catch(() => { + DbQueryCache.invalidateByTable(tableName); + }); + return promise; + }; + } + + return result; + }; } - return result -} +}; -const originalDel = buildKnex.QueryBuilder.prototype.del -buildKnex.QueryBuilder.prototype.del = function(...args) { - const tableName = this._single?.table - const result = originalDel.apply(this, args) - if (tableName) { - result.then(() => { - DbQueryCache.invalidateByTable(tableName) - }).catch(() => { - DbQueryCache.invalidateByTable(tableName) - }) - } - return result -} +// 安全地扩展 CUD 方法 +addCacheInvalidation('insert'); +addCacheInvalidation('update'); +addCacheInvalidation('del'); const environment = process.env.NODE_ENV || "development" const db = buildKnex(knexConfig[environment]) diff --git a/src/db/models/SiteConfigModel.js b/src/db/models/SiteConfigModel.js index 1d291b4..d8340ae 100644 --- a/src/db/models/SiteConfigModel.js +++ b/src/db/models/SiteConfigModel.js @@ -39,7 +39,7 @@ class SiteConfigModel extends BaseModel { // 获取所有配置 static async getAll() { - const rows = await db(this.tableName).select("key", "value") + const rows = await db(this.tableName).select("key", "value").cache() const result = {} rows.forEach(row => { result[row.key] = row.value diff --git a/src/logger.js b/src/logger.js index 06392df..9dacabe 100644 --- a/src/logger.js +++ b/src/logger.js @@ -52,12 +52,11 @@ log4js.configure({ }, categories: { jobs: { appenders: ["console", "jobs"], level: "info" }, - error: { appenders: ["console", "error"], level: "error" }, - default: { appenders: ["console", "all", "error"], level: "all" }, + // error: { appenders: ["console", "error"], level: "error" }, + default: { appenders: ["console", "all"], 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..07f5261 100644 --- a/src/main.js +++ b/src/main.js @@ -9,33 +9,36 @@ import os from "os" // 应用插件与自动路由 import LoadMiddlewares from "./middlewares/install.js" -// 注册插件 -LoadMiddlewares(app) - -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 PORT = process.env.PORT || 3000; + +; (async () => { + + // 注册插件 + await LoadMiddlewares(app) + + 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`) -}) - -export default app + 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`) + }) + + +})() diff --git a/src/middlewares/Auth/index.js b/src/middlewares/Auth/index.js index 4f2d4e8..96dbdd3 100644 --- a/src/middlewares/Auth/index.js +++ b/src/middlewares/Auth/index.js @@ -49,16 +49,16 @@ export function AuthMiddleware(options = { export function VerifyUserMiddleware() { return (ctx, next) => { if (ctx.session.user) { - ctx.user = ctx.session.user + ctx.state.user = ctx.session.user } else { const authorizationString = ctx.headers["authorization"] if (authorizationString) { const token = authorizationString.replace(/^Bearer\s/, "") - ctx.user = jwt.verify(token, process.env.JWT_SECRET) + ctx.state.user = jwt.verify(token, process.env.JWT_SECRET) } } if (ctx.authType === false) { - if (ctx.user) { + if (ctx.state.user) { throw new CommonError("该接口不能登录查看") } return next() @@ -66,7 +66,7 @@ export function VerifyUserMiddleware() { if (ctx.authType === "try") { return next() } - if (!ctx.user && ctx.authType === true) { + if (!ctx.state.user && ctx.authType === true) { throw new CommonError("请登录") } return next() diff --git a/src/middlewares/Views/index.js b/src/middlewares/Views/index.js index 9508101..e323d43 100644 --- a/src/middlewares/Views/index.js +++ b/src/middlewares/Views/index.js @@ -4,10 +4,8 @@ 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" +import { logger } from "@/logger" export default viewsMiddleware @@ -19,22 +17,27 @@ function viewsMiddleware(path, { engineSource = consolidate, extension = "html", // 将 render 注入到 context 和 response 对象中 ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) { - renderOptions = assign({ includeSite: true, includeUser: true }, renderOptions || {}) + renderOptions = assign({}, 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.session && !!ctx.session.user, } - // if (renderOptions.includeSite) { - // otherData.$site = site - // } - // if (renderOptions.includeUser && ctx.session && ctx.session.user) { - // otherData.$user = ctx.session.user - // } - const state = assign({}, otherData, locals, options, ctx.state || {}) + const state = assign( + { + filters: { + "my-own-filter": function (text, options) { + if (options.addStart) text = "Start\n" + text + if (options.addEnd) text = text + "\nEnd" + return text + }, + }, + }, + otherData, + locals, + options, + ctx.state || {} + ) // deep copy partials state.partials = assign({}, options.partials || {}) // logger.debug("render `%s` with %j", paths.rel, state) diff --git a/src/middlewares/errorHandler/index.js b/src/middlewares/errorHandler/index.js index 816dce4..6023895 100644 --- a/src/middlewares/errorHandler/index.js +++ b/src/middlewares/errorHandler/index.js @@ -1,43 +1,102 @@ import { logger } from "@/logger" -// src/plugins/errorHandler.js -// 错误处理中间件插件 +import AuthError from "@/utils/error/AuthError" +import BaseError from "@/utils/error/BaseError.js" +import CommonError from "@/utils/error/CommonError.js" +/** + * 格式化错误响应 + * @param {Object} ctx - Koa上下文 + * @param {number} status - HTTP状态码 + * @param {string} message - 错误消息 + * @param {string} stack - 错误堆栈(仅开发环境) + * @returns {Promise} + */ async function formatError(ctx, status, message, stack) { const accept = ctx.accepts("json", "html", "text") const isDev = process.env.NODE_ENV === "development" + + // 确保状态码在合理范围内 + status = status >= 100 && status < 600 ? status : 500 + if (accept === "json") { ctx.type = "application/json" - ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } + ctx.body = isDev && stack ? + { success: false, error: message, stack, status } : + { success: false, error: message, status } } 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.body = isDev && stack ? + `${status} - ${message}\n${stack}` : + `${status} - ${message}` } ctx.status = status } -export default function errorHandler() { +/** + * 错误处理中间件 + * @returns {Function} Koa中间件函数 + */ +export default function () { 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) { + // 处理404情况 - 只有在没有设置body且状态码为404时才处理 + if (ctx.status === 404 && !ctx.body) { await formatError(ctx, 404, "Resource not found") } } catch (err) { - logger.error(err) + if (err instanceof AuthError) { + ctx.redirect('/no-auth?from=' + err.ctx.url) + return + } + // 记录错误日志,包含更多上下文信息 + logger.error({ + message: "Unhandled error occurred", + error: err.message, + stack: err.stack, + url: ctx.url, + method: ctx.method, + ip: ctx.ip, + userAgent: ctx.headers['user-agent'] + }) + const isDev = process.env.NODE_ENV === "development" - if (isDev && err.stack) { - console.error(err.stack) + + // 开发环境下在控制台输出错误堆栈 + // if (isDev && err.stack) { + // console.error("\x1b[31m%s\x1b[0m", err.stack) + // } + + // 根据错误类型设置适当的状态码和消息 + let status = 500 + let message = "Internal server error" + + // 处理自定义错误类型 + if (err instanceof BaseError) { + status = err.statusCode || 500 + message = err.message || message + } else if (err.status) { + // 处理Koa内置错误对象 + status = err.status + message = err.message || message + } else if (err.statusCode) { + // 处理其他带有状态码的错误对象 + status = err.statusCode + message = err.message || message } - await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) + + // 确保状态码在合理范围内 + status = status >= 100 && status < 600 ? status : 500 + + await formatError( + ctx, + status, + message, + isDev ? err.stack : undefined + ) } } -} +} \ No newline at end of file diff --git a/src/middlewares/install.js b/src/middlewares/install.js index e1b021d..7a494f0 100644 --- a/src/middlewares/install.js +++ b/src/middlewares/install.js @@ -14,6 +14,9 @@ import { autoRegisterControllers } from "@/utils/ForRegister.js" import performanceMonitor from "./RoutePerformance/index.js" import app from "@/global" +import { SiteConfigService } from "services/SiteConfigService.js" +import config from "config/index.js" + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicPath = resolve(__dirname, "../../public") @@ -21,11 +24,36 @@ const publicPath = resolve(__dirname, "../../public") * 注册中间件 * @param {app} app */ -export default app => { - // 错误处理 - app.use(ErrorHandler()) +export default async app => { // 响应时间 app.use(ResponseTime) + // 拦截 Chrome DevTools 探测请求,直接返回 204 + app.use((ctx, next) => { + if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { + ctx.status = 204 + ctx.body = "" + return + } + return next() + }) + app.use(async (ctx, next) => { + ctx.set("Access-Control-Allow-Origin", "*") + ctx.set("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS") + ctx.set("Access-Control-Allow-Headers", "Content-Type,Content-Length, Authorization, Accept,X-Requested-With") + ctx.set("Access-Control-Allow-Credentials", true) + if (ctx.method == "OPTIONS") { + ctx.status = 200 + } + return await next() + }) + app.use(async (ctx, next) => { + // 提供全局数据 + ctx.state.siteConfig = await SiteConfigService.getAll() + ctx.state.base = config.base + return await next() + }) + // 错误处理,主要处理运行中抛出的错误 + app.use(ErrorHandler()) // 路由性能监控(在路由处理之前) app.use(performanceMonitor.middleware()) // session设置 @@ -49,9 +77,9 @@ export default app => { ], blackList: [ // 禁用api请求 - "/api", - "/api/", - "/api/**/*", + // "/api", + // "/api/", + // "/api/**/*", ], }) ) @@ -60,7 +88,7 @@ export default app => { // 请求体解析 app.use(bodyParser()) // 自动注册控制器 - autoRegisterControllers(app, path.resolve(__dirname, "../controllers")) + await autoRegisterControllers(app, path.resolve(__dirname, "../controllers")) // 注册完成之后静态资源设置 app.use(async (ctx, next) => { if (ctx.body) return await next() diff --git a/src/services/ArticleService.js b/src/services/ArticleService.js new file mode 100644 index 0000000..1e7d8b0 --- /dev/null +++ b/src/services/ArticleService.js @@ -0,0 +1,563 @@ +import ArticleModel from "../db/models/ArticleModel.js" +import { logger } from "../logger.js" + +/** + * 文章服务类 + * 提供文章相关的业务逻辑 + */ +class ArticleService { + /** + * 创建新文章 + * @param {Object} articleData - 文章数据 + * @returns {Promise} 创建的文章信息 + */ + static async createArticle(articleData) { + try { + // 数据验证 + this.validateArticleData(articleData) + + // 创建文章 + const article = await ArticleModel.create(articleData) + + logger.info(`文章创建成功: ${article.title} (ID: ${article.id})`) + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`创建文章失败:`, error) + throw error + } + } + + /** + * 根据ID获取文章 + * @param {number} id - 文章ID + * @param {boolean} incrementView - 是否增加浏览量 + * @returns {Promise} 文章信息 + */ + static async getArticleById(id, incrementView = false) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + return null + } + + // 如果需要增加浏览量 + if (incrementView) { + await ArticleModel.incrementViewCount(id) + article.view_count = (article.view_count || 0) + 1 + } + + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`获取文章失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 根据slug获取文章 + * @param {string} slug - 文章slug + * @param {boolean} incrementView - 是否增加浏览量 + * @returns {Promise} 文章信息 + */ + static async getArticleBySlug(slug, incrementView = false) { + try { + const article = await ArticleModel.findBySlug(slug) + if (!article) { + return null + } + + // 如果需要增加浏览量 + if (incrementView) { + await ArticleModel.incrementViewCount(article.id) + article.view_count = (article.view_count || 0) + 1 + } + + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`根据slug获取文章失败 (${slug}):`, error) + throw error + } + } + + /** + * 更新文章 + * @param {number} id - 文章ID + * @param {Object} updateData - 更新数据 + * @returns {Promise} 更新后的文章信息 + */ + static async updateArticle(id, updateData) { + try { + // 验证文章是否存在 + const existingArticle = await ArticleModel.findById(id) + if (!existingArticle) { + throw new Error("文章不存在") + } + + // 数据验证 + this.validateArticleUpdateData(updateData) + + // 更新文章 + const article = await ArticleModel.update(id, updateData) + + logger.info(`文章更新成功: ${article.title} (ID: ${id})`) + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`更新文章失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 删除文章 + * @param {number} id - 文章ID + * @returns {Promise} 删除结果 + */ + static async deleteArticle(id) { + try { + const article = await ArticleModel.findById(id) + if (!article) { + throw new Error("文章不存在") + } + + const result = await ArticleModel.delete(id) + + logger.info(`文章删除成功: ${article.title} (ID: ${id})`) + return result > 0 + } catch (error) { + logger.error(`删除文章失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 发布文章 + * @param {number} id - 文章ID + * @returns {Promise} 发布后的文章信息 + */ + static async publishArticle(id) { + try { + const article = await ArticleModel.publish(id) + logger.info(`文章发布成功: ${article.title} (ID: ${id})`) + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`发布文章失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 取消发布文章 + * @param {number} id - 文章ID + * @returns {Promise} 取消发布后的文章信息 + */ + static async unpublishArticle(id) { + try { + const article = await ArticleModel.unpublish(id) + logger.info(`文章取消发布成功: ${article.title} (ID: ${id})`) + return this.formatArticleResponse(article) + } catch (error) { + logger.error(`取消发布文章失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 获取文章列表 + * @param {Object} options - 查询选项 + * @returns {Promise} 文章列表和分页信息 + */ + static async getArticleList(options = {}) { + try { + const { + page = 1, + limit = 10, + search = "", + category = null, + status = null, + author = null, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (category) where.category = category + if (status) where.status = status + if (author) where.author = author + + const result = await ArticleModel.paginate({ + page, + limit, + where, + search, + searchFields: ArticleModel.searchableFields, + orderBy, + order + }) + + return { + articles: result.data.map(article => this.formatArticleResponse(article)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取文章列表失败:`, error) + throw error + } + } + + /** + * 获取已发布文章列表 + * @param {Object} options - 查询选项 + * @returns {Promise} 文章列表和分页信息 + */ + static async getPublishedArticles(options = {}) { + try { + const { + page = 1, + limit = 10, + search = "", + category = null, + author = null, + orderBy = "published_at", + order = "desc" + } = options + + const where = { status: "published" } + if (category) where.category = category + if (author) where.author = author + + const result = await ArticleModel.paginate({ + page, + limit, + where, + search, + searchFields: ArticleModel.searchableFields, + orderBy, + order + }) + + return { + articles: result.data.map(article => this.formatArticleResponse(article)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取已发布文章列表失败:`, error) + throw error + } + } + + /** + * 获取作者文章列表 + * @param {string} author - 作者用户名 + * @param {Object} options - 查询选项 + * @returns {Promise} 文章列表和分页信息 + */ + static async getAuthorArticles(author, options = {}) { + try { + const { + page = 1, + limit = 10, + status = null, + search = "", + orderBy = "updated_at", + order = "desc" + } = options + + const where = { author } + if (status) where.status = status + + const result = await ArticleModel.paginate({ + page, + limit, + where, + search, + searchFields: ArticleModel.searchableFields, + orderBy, + order + }) + + return { + articles: result.data.map(article => this.formatArticleResponse(article)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取作者文章列表失败 (${author}):`, error) + throw error + } + } + + /** + * 根据分类获取文章 + * @param {string} category - 分类 + * @param {Object} options - 查询选项 + * @returns {Promise} 文章列表 + */ + static async getArticlesByCategory(category, options = {}) { + try { + const { limit = 20 } = options + const articles = await ArticleModel.findByCategoryWithAuthor(category, limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`根据分类获取文章失败 (${category}):`, error) + throw error + } + } + + /** + * 根据标签获取文章 + * @param {string} tags - 标签(逗号分隔) + * @param {Object} options - 查询选项 + * @returns {Promise} 文章列表 + */ + static async getArticlesByTags(tags, options = {}) { + try { + const articles = await ArticleModel.findByTags(tags) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`根据标签获取文章失败 (${tags}):`, error) + throw error + } + } + + /** + * 搜索文章 + * @param {string} keyword - 搜索关键词 + * @param {Object} options - 搜索选项 + * @returns {Promise} 搜索结果 + */ + static async searchArticles(keyword, options = {}) { + try { + const { limit = 20 } = options + const articles = await ArticleModel.searchWithAuthor(keyword, limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`搜索文章失败:`, error) + throw error + } + } + + /** + * 获取最新文章 + * @param {number} limit - 数量限制 + * @returns {Promise} 最新文章列表 + */ + static async getRecentArticles(limit = 10) { + try { + const articles = await ArticleModel.getRecentArticlesWithAuthor(limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`获取最新文章失败:`, error) + throw error + } + } + + /** + * 获取热门文章 + * @param {number} limit - 数量限制 + * @returns {Promise} 热门文章列表 + */ + static async getPopularArticles(limit = 10) { + try { + const articles = await ArticleModel.getPopularArticlesWithAuthor(limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`获取热门文章失败:`, error) + throw error + } + } + + /** + * 获取精选文章 + * @param {number} limit - 数量限制 + * @returns {Promise} 精选文章列表 + */ + static async getFeaturedArticles(limit = 5) { + try { + const articles = await ArticleModel.getFeaturedArticlesWithAuthor(limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`获取精选文章失败:`, error) + throw error + } + } + + /** + * 获取相关文章 + * @param {number} articleId - 文章ID + * @param {number} limit - 数量限制 + * @returns {Promise} 相关文章列表 + */ + static async getRelatedArticles(articleId, limit = 5) { + try { + const articles = await ArticleModel.getRelatedArticles(articleId, limit) + return articles.map(article => this.formatArticleResponse(article)) + } catch (error) { + logger.error(`获取相关文章失败 (ID: ${articleId}):`, error) + throw error + } + } + + /** + * 获取文章统计信息 + * @returns {Promise} 统计信息 + */ + static async getArticleStats() { + try { + const [ + total, + published, + drafts, + byCategory, + byStatus + ] = await Promise.all([ + ArticleModel.getArticleCount(), + ArticleModel.getPublishedArticleCount(), + ArticleModel.count({ status: "draft" }), + ArticleModel.getArticleCountByCategory(), + ArticleModel.getArticleCountByStatus() + ]) + + return { + total, + published, + drafts, + byCategory, + byStatus + } + } catch (error) { + logger.error(`获取文章统计失败:`, error) + throw error + } + } + + /** + * 验证文章数据 + * @param {Object} articleData - 文章数据 + */ + static validateArticleData(articleData) { + if (!articleData.title) { + throw new Error("文章标题不能为空") + } + if (!articleData.content) { + throw new Error("文章内容不能为空") + } + if (!articleData.author) { + throw new Error("文章作者不能为空") + } + + // 标题长度验证 + if (articleData.title.length > 200) { + throw new Error("文章标题不能超过200个字符") + } + + // 内容长度验证 + if (articleData.content.length < 10) { + throw new Error("文章内容不能少于10个字符") + } + } + + /** + * 验证文章更新数据 + * @param {Object} updateData - 更新数据 + */ + static validateArticleUpdateData(updateData) { + if (updateData.title && updateData.title.length > 200) { + throw new Error("文章标题不能超过200个字符") + } + + if (updateData.content && updateData.content.length < 10) { + throw new Error("文章内容不能少于10个字符") + } + } + + /** + * 格式化文章响应数据 + * @param {Object} article - 文章数据 + * @returns {Object} 格式化后的文章数据 + */ + static formatArticleResponse(article) { + return { + ...article, + // 确保数字字段为数字类型 + id: parseInt(article.id), + view_count: parseInt(article.view_count) || 0, + reading_time: parseInt(article.reading_time) || 0, + // 格式化日期字段 + created_at: article.created_at, + updated_at: article.updated_at, + published_at: article.published_at + } + } + + /** + * 批量更新文章状态 + * @param {Array} ids - 文章ID数组 + * @param {string} status - 新状态 + * @returns {Promise} 更新数量 + */ + static async batchUpdateStatus(ids, status) { + try { + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error("文章ID数组不能为空") + } + + if (!["draft", "published", "archived"].includes(status)) { + throw new Error("无效的文章状态") + } + + const result = await ArticleModel.updateMany( + { id: ids }, + { status } + ) + + logger.info(`批量更新文章状态成功: ${ids.length} 篇文章状态更新为 ${status}`) + return result + } catch (error) { + logger.error(`批量更新文章状态失败:`, error) + throw error + } + } + + /** + * 获取文章分类统计 + * @returns {Promise} 分类统计 + */ + static async getCategoryStats() { + try { + return await ArticleModel.getArticleCountByCategory() + } catch (error) { + logger.error(`获取文章分类统计失败:`, error) + throw error + } + } + + /** + * 获取文章标签列表 + * @returns {Promise} 标签列表 + */ + static async getTagList() { + try { + const articles = await ArticleModel.findWhere( + { status: "published" }, + { select: ["tags"] } + ) + + const tagSet = new Set() + articles.forEach(article => { + if (article.tags) { + const tags = article.tags.split(",").map(tag => tag.trim()) + tags.forEach(tag => { + if (tag) tagSet.add(tag) + }) + } + }) + + return Array.from(tagSet).sort() + } catch (error) { + logger.error(`获取文章标签列表失败:`, error) + throw error + } + } +} + +export default ArticleService +export { ArticleService } diff --git a/src/services/BookmarkService.js b/src/services/BookmarkService.js new file mode 100644 index 0000000..64a5920 --- /dev/null +++ b/src/services/BookmarkService.js @@ -0,0 +1,492 @@ +import BookmarkModel from "../db/models/BookmarkModel.js" +import { logger } from "../logger.js" + +/** + * 书签服务类 + * 提供书签相关的业务逻辑 + */ +class BookmarkService { + /** + * 创建新书签 + * @param {Object} bookmarkData - 书签数据 + * @returns {Promise} 创建的书签信息 + */ + static async createBookmark(bookmarkData) { + try { + // 数据验证 + this.validateBookmarkData(bookmarkData) + + // 创建书签 + const bookmark = await BookmarkModel.create(bookmarkData) + + logger.info(`书签创建成功: ${bookmark.title} (ID: ${bookmark.id})`) + return this.formatBookmarkResponse(bookmark) + } catch (error) { + logger.error(`创建书签失败:`, error) + throw error + } + } + + /** + * 根据ID获取书签 + * @param {number} id - 书签ID + * @returns {Promise} 书签信息 + */ + static async getBookmarkById(id) { + try { + const bookmark = await BookmarkModel.findById(id) + return bookmark ? this.formatBookmarkResponse(bookmark) : null + } catch (error) { + logger.error(`获取书签失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 更新书签 + * @param {number} id - 书签ID + * @param {Object} updateData - 更新数据 + * @returns {Promise} 更新后的书签信息 + */ + static async updateBookmark(id, updateData) { + try { + // 验证书签是否存在 + const existingBookmark = await BookmarkModel.findById(id) + if (!existingBookmark) { + throw new Error("书签不存在") + } + + // 数据验证 + this.validateBookmarkUpdateData(updateData) + + // 更新书签 + const bookmark = await BookmarkModel.update(id, updateData) + + logger.info(`书签更新成功: ${bookmark.title} (ID: ${id})`) + return this.formatBookmarkResponse(bookmark) + } catch (error) { + logger.error(`更新书签失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 删除书签 + * @param {number} id - 书签ID + * @returns {Promise} 删除结果 + */ + static async deleteBookmark(id) { + try { + const bookmark = await BookmarkModel.findById(id) + if (!bookmark) { + throw new Error("书签不存在") + } + + const result = await BookmarkModel.delete(id) + + logger.info(`书签删除成功: ${bookmark.title} (ID: ${id})`) + return result > 0 + } catch (error) { + logger.error(`删除书签失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 获取用户书签列表 + * @param {number} userId - 用户ID + * @param {Object} options - 查询选项 + * @returns {Promise} 书签列表和分页信息 + */ + static async getUserBookmarks(userId, options = {}) { + try { + const { + page = 1, + limit = 20, + search = "", + orderBy = "created_at", + order = "desc" + } = options + + const result = await BookmarkModel.findByUserWithPagination(userId, { + page, + limit, + search, + searchFields: BookmarkModel.searchableFields, + orderBy, + order + }) + + return { + bookmarks: result.data.map(bookmark => this.formatBookmarkResponse(bookmark)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取用户书签列表失败 (用户ID: ${userId}):`, error) + throw error + } + } + + /** + * 获取所有书签列表(管理员) + * @param {Object} options - 查询选项 + * @returns {Promise} 书签列表和分页信息 + */ + static async getAllBookmarks(options = {}) { + try { + const { + page = 1, + limit = 20, + search = "", + userId = null, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (userId) where.user_id = userId + + const result = await BookmarkModel.paginate({ + page, + limit, + where, + search, + searchFields: BookmarkModel.searchableFields, + orderBy, + order + }) + + return { + bookmarks: result.data.map(bookmark => this.formatBookmarkResponse(bookmark)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取所有书签列表失败:`, error) + throw error + } + } + + /** + * 获取书签及其用户信息 + * @param {Object} options - 查询选项 + * @returns {Promise} 书签列表 + */ + static async getBookmarksWithUsers(options = {}) { + try { + const { limit = 50, orderBy = "created_at", order = "desc" } = options + const bookmarks = await BookmarkModel.findAllWithUsers({ limit, orderBy, order }) + return bookmarks.map(bookmark => this.formatBookmarkResponse(bookmark)) + } catch (error) { + logger.error(`获取书签及用户信息失败:`, error) + throw error + } + } + + /** + * 获取热门书签 + * @param {number} limit - 数量限制 + * @returns {Promise} 热门书签列表 + */ + static async getPopularBookmarks(limit = 10) { + try { + const bookmarks = await BookmarkModel.getPopularBookmarks(limit) + return bookmarks.map(bookmark => ({ + url: bookmark.url, + title: bookmark.title, + bookmark_count: parseInt(bookmark.bookmark_count), + latest_bookmark: bookmark.latest_bookmark + })) + } catch (error) { + logger.error(`获取热门书签失败:`, error) + throw error + } + } + + /** + * 搜索书签 + * @param {string} keyword - 搜索关键词 + * @param {Object} options - 搜索选项 + * @returns {Promise} 搜索结果 + */ + static async searchBookmarks(keyword, options = {}) { + try { + const { + userId = null, + limit = 20, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (userId) where.user_id = userId + + const bookmarks = await BookmarkModel.findWhere(where, { + search: keyword, + searchFields: BookmarkModel.searchableFields, + limit, + orderBy, + order + }) + + return bookmarks.map(bookmark => this.formatBookmarkResponse(bookmark)) + } catch (error) { + logger.error(`搜索书签失败:`, error) + throw error + } + } + + /** + * 检查书签是否存在 + * @param {number} userId - 用户ID + * @param {string} url - URL + * @returns {Promise} 是否存在 + */ + static async checkBookmarkExists(userId, url) { + try { + const bookmark = await BookmarkModel.findByUserAndUrl(userId, url) + return !!bookmark + } catch (error) { + logger.error(`检查书签是否存在失败:`, error) + throw error + } + } + + /** + * 获取用户书签统计 + * @param {number} userId - 用户ID + * @returns {Promise} 统计信息 + */ + static async getUserBookmarkStats(userId) { + try { + return await BookmarkModel.getUserBookmarkStats(userId) + } catch (error) { + logger.error(`获取用户书签统计失败 (用户ID: ${userId}):`, error) + throw error + } + } + + /** + * 批量删除书签 + * @param {Array} ids - 书签ID数组 + * @param {number} userId - 用户ID(可选,用于权限验证) + * @returns {Promise} 删除数量 + */ + static async batchDeleteBookmarks(ids, userId = null) { + try { + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error("书签ID数组不能为空") + } + + // 如果提供了用户ID,验证书签是否属于该用户 + if (userId) { + const bookmarks = await BookmarkModel.findWhere({ id: ids, user_id: userId }) + if (bookmarks.length !== ids.length) { + throw new Error("部分书签不存在或无权限删除") + } + } + + const result = await BookmarkModel.deleteWhere({ id: ids }) + + logger.info(`批量删除书签成功: ${result} 个书签`) + return result + } catch (error) { + logger.error(`批量删除书签失败:`, error) + throw error + } + } + + /** + * 批量创建书签 + * @param {Array} bookmarksData - 书签数据数组 + * @returns {Promise} 创建结果 + */ + static async batchCreateBookmarks(bookmarksData) { + try { + const results = [] + const errors = [] + + for (let i = 0; i < bookmarksData.length; i++) { + try { + const bookmarkData = bookmarksData[i] + this.validateBookmarkData(bookmarkData) + + const bookmark = await BookmarkModel.create(bookmarkData) + results.push(this.formatBookmarkResponse(bookmark)) + } catch (error) { + errors.push({ + index: i, + data: bookmarksData[i], + error: error.message + }) + } + } + + return { + success: results, + errors, + summary: { + total: bookmarksData.length, + success: results.length, + failed: errors.length + } + } + } catch (error) { + logger.error(`批量创建书签失败:`, error) + throw error + } + } + + /** + * 获取书签分类统计 + * @param {number} userId - 用户ID(可选) + * @returns {Promise} 分类统计 + */ + static async getCategoryStats(userId = null) { + try { + const where = userId ? { user_id: userId } : {} + const bookmarks = await BookmarkModel.findWhere(where, { select: ["category"] }) + + const categoryStats = {} + bookmarks.forEach(bookmark => { + const category = bookmark.category || "未分类" + categoryStats[category] = (categoryStats[category] || 0) + 1 + }) + + return Object.entries(categoryStats) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count) + } catch (error) { + logger.error(`获取书签分类统计失败:`, error) + throw error + } + } + + /** + * 验证书签数据 + * @param {Object} bookmarkData - 书签数据 + */ + static validateBookmarkData(bookmarkData) { + if (!bookmarkData.user_id) { + throw new Error("用户ID不能为空") + } + if (!bookmarkData.url) { + throw new Error("URL不能为空") + } + if (!bookmarkData.title) { + throw new Error("书签标题不能为空") + } + + // URL格式验证 + const urlRegex = /^https?:\/\/.+\..+/ + if (!urlRegex.test(bookmarkData.url)) { + throw new Error("URL格式不正确") + } + + // 标题长度验证 + if (bookmarkData.title.length > 200) { + throw new Error("书签标题不能超过200个字符") + } + + // 描述长度验证 + if (bookmarkData.description && bookmarkData.description.length > 500) { + throw new Error("书签描述不能超过500个字符") + } + } + + /** + * 验证书签更新数据 + * @param {Object} updateData - 更新数据 + */ + static validateBookmarkUpdateData(updateData) { + if (updateData.url) { + const urlRegex = /^https?:\/\/.+\..+/ + if (!urlRegex.test(updateData.url)) { + throw new Error("URL格式不正确") + } + } + + if (updateData.title && updateData.title.length > 200) { + throw new Error("书签标题不能超过200个字符") + } + + if (updateData.description && updateData.description.length > 500) { + throw new Error("书签描述不能超过500个字符") + } + } + + /** + * 格式化书签响应数据 + * @param {Object} bookmark - 书签数据 + * @returns {Object} 格式化后的书签数据 + */ + static formatBookmarkResponse(bookmark) { + return { + ...bookmark, + // 确保数字字段为数字类型 + id: parseInt(bookmark.id), + user_id: parseInt(bookmark.user_id), + // 格式化日期字段 + created_at: bookmark.created_at, + updated_at: bookmark.updated_at + } + } + + /** + * 导入书签 + * @param {number} userId - 用户ID + * @param {Array} bookmarksData - 书签数据数组 + * @returns {Promise} 导入结果 + */ + static async importBookmarks(userId, bookmarksData) { + try { + const results = [] + const errors = [] + const skipped = [] + + for (let i = 0; i < bookmarksData.length; i++) { + try { + const bookmarkData = { ...bookmarksData[i], user_id: userId } + this.validateBookmarkData(bookmarkData) + + // 检查是否已存在 + const exists = await this.checkBookmarkExists(userId, bookmarkData.url) + if (exists) { + skipped.push({ + index: i, + data: bookmarkData, + reason: "书签已存在" + }) + continue + } + + const bookmark = await BookmarkModel.create(bookmarkData) + results.push(this.formatBookmarkResponse(bookmark)) + } catch (error) { + errors.push({ + index: i, + data: bookmarksData[i], + error: error.message + }) + } + } + + return { + success: results, + errors, + skipped, + summary: { + total: bookmarksData.length, + success: results.length, + failed: errors.length, + skipped: skipped.length + } + } + } catch (error) { + logger.error(`导入书签失败:`, error) + throw error + } + } +} + +export default BookmarkService +export { BookmarkService } diff --git a/src/services/ContactService.js b/src/services/ContactService.js new file mode 100644 index 0000000..293903d --- /dev/null +++ b/src/services/ContactService.js @@ -0,0 +1,511 @@ +import ContactModel from "../db/models/ContactModel.js" +import { logger } from "../logger.js" + +/** + * 联系信息服务类 + * 提供联系信息相关的业务逻辑 + */ +class ContactService { + /** + * 创建新联系信息 + * @param {Object} contactData - 联系信息数据 + * @returns {Promise} 创建的联系信息 + */ + static async createContact(contactData) { + try { + // 数据验证 + this.validateContactData(contactData) + + // 创建联系信息 + const contact = await ContactModel.create(contactData) + + logger.info(`联系信息创建成功: ${contact.name} (ID: ${contact.id})`) + return this.formatContactResponse(contact) + } catch (error) { + logger.error(`创建联系信息失败:`, error) + throw error + } + } + + /** + * 根据ID获取联系信息 + * @param {number} id - 联系信息ID + * @returns {Promise} 联系信息 + */ + static async getContactById(id) { + try { + const contact = await ContactModel.findById(id) + return contact ? this.formatContactResponse(contact) : null + } catch (error) { + logger.error(`获取联系信息失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 更新联系信息 + * @param {number} id - 联系信息ID + * @param {Object} updateData - 更新数据 + * @returns {Promise} 更新后的联系信息 + */ + static async updateContact(id, updateData) { + try { + // 验证联系信息是否存在 + const existingContact = await ContactModel.findById(id) + if (!existingContact) { + throw new Error("联系信息不存在") + } + + // 数据验证 + this.validateContactUpdateData(updateData) + + // 更新联系信息 + const contact = await ContactModel.update(id, updateData) + + logger.info(`联系信息更新成功: ${contact.name} (ID: ${id})`) + return this.formatContactResponse(contact) + } catch (error) { + logger.error(`更新联系信息失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 删除联系信息 + * @param {number} id - 联系信息ID + * @returns {Promise} 删除结果 + */ + static async deleteContact(id) { + try { + const contact = await ContactModel.findById(id) + if (!contact) { + throw new Error("联系信息不存在") + } + + const result = await ContactModel.delete(id) + + logger.info(`联系信息删除成功: ${contact.name} (ID: ${id})`) + return result > 0 + } catch (error) { + logger.error(`删除联系信息失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 获取联系信息列表 + * @param {Object} options - 查询选项 + * @returns {Promise} 联系信息列表和分页信息 + */ + static async getContactList(options = {}) { + try { + const { + page = 1, + limit = 20, + search = "", + status = null, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (status) where.status = status + + const result = await ContactModel.paginate({ + page, + limit, + where, + search, + searchFields: ContactModel.searchableFields, + orderBy, + order + }) + + return { + contacts: result.data.map(contact => this.formatContactResponse(contact)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取联系信息列表失败:`, error) + throw error + } + } + + /** + * 根据邮箱获取联系信息 + * @param {string} email - 邮箱 + * @returns {Promise} 联系信息列表 + */ + static async getContactsByEmail(email) { + try { + const contacts = await ContactModel.findByEmail(email) + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`根据邮箱获取联系信息失败 (${email}):`, error) + throw error + } + } + + /** + * 根据状态获取联系信息 + * @param {string} status - 状态 + * @returns {Promise} 联系信息列表 + */ + static async getContactsByStatus(status) { + try { + const contacts = await ContactModel.findByStatus(status) + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`根据状态获取联系信息失败 (${status}):`, error) + throw error + } + } + + /** + * 根据日期范围获取联系信息 + * @param {string} startDate - 开始日期 + * @param {string} endDate - 结束日期 + * @returns {Promise} 联系信息列表 + */ + static async getContactsByDateRange(startDate, endDate) { + try { + const contacts = await ContactModel.findByDateRange(startDate, endDate) + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`根据日期范围获取联系信息失败:`, error) + throw error + } + } + + /** + * 标记为已读 + * @param {number} id - 联系信息ID + * @returns {Promise} 更新后的联系信息 + */ + static async markAsRead(id) { + try { + const contact = await ContactModel.markAsRead(id) + logger.info(`联系信息标记为已读成功 (ID: ${id})`) + return this.formatContactResponse(contact) + } catch (error) { + logger.error(`标记联系信息为已读失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 标记为已回复 + * @param {number} id - 联系信息ID + * @returns {Promise} 更新后的联系信息 + */ + static async markAsReplied(id) { + try { + const contact = await ContactModel.markAsReplied(id) + logger.info(`联系信息标记为已回复成功 (ID: ${id})`) + return this.formatContactResponse(contact) + } catch (error) { + logger.error(`标记联系信息为已回复失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 批量更新状态 + * @param {Array} ids - 联系信息ID数组 + * @param {string} status - 新状态 + * @returns {Promise} 更新数量 + */ + static async batchUpdateStatus(ids, status) { + try { + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error("联系信息ID数组不能为空") + } + + if (!["unread", "read", "replied"].includes(status)) { + throw new Error("无效的联系信息状态") + } + + const result = await ContactModel.updateStatusBatchByIds(ids, status) + + logger.info(`批量更新联系信息状态成功: ${ids.length} 条记录状态更新为 ${status}`) + return result + } catch (error) { + logger.error(`批量更新联系信息状态失败:`, error) + throw error + } + } + + /** + * 批量删除联系信息 + * @param {Array} ids - 联系信息ID数组 + * @returns {Promise} 删除数量 + */ + static async batchDeleteContacts(ids) { + try { + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error("联系信息ID数组不能为空") + } + + const result = await ContactModel.deleteWhere({ id: ids }) + + logger.info(`批量删除联系信息成功: ${result} 条记录`) + return result + } catch (error) { + logger.error(`批量删除联系信息失败:`, error) + throw error + } + } + + /** + * 获取联系信息统计 + * @returns {Promise} 统计信息 + */ + static async getContactStats() { + try { + const stats = await ContactModel.getStats() + const todayCount = await ContactModel.getTodayCount() + + return { + ...stats, + today: todayCount + } + } catch (error) { + logger.error(`获取联系信息统计失败:`, error) + throw error + } + } + + /** + * 获取今日新联系数量 + * @returns {Promise} 今日新联系数量 + */ + static async getTodayContactCount() { + try { + return await ContactModel.getTodayCount() + } catch (error) { + logger.error(`获取今日新联系数量失败:`, error) + throw error + } + } + + /** + * 搜索联系信息 + * @param {string} keyword - 搜索关键词 + * @param {Object} options - 搜索选项 + * @returns {Promise} 搜索结果 + */ + static async searchContacts(keyword, options = {}) { + try { + const { + status = null, + limit = 20, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (status) where.status = status + + const contacts = await ContactModel.findWhere(where, { + search: keyword, + searchFields: ContactModel.searchableFields, + limit, + orderBy, + order + }) + + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`搜索联系信息失败:`, error) + throw error + } + } + + /** + * 获取未读联系信息数量 + * @returns {Promise} 未读数量 + */ + static async getUnreadCount() { + try { + return await ContactModel.count({ status: "unread" }) + } catch (error) { + logger.error(`获取未读联系信息数量失败:`, error) + throw error + } + } + + /** + * 获取最近联系信息 + * @param {number} limit - 数量限制 + * @returns {Promise} 最近联系信息列表 + */ + static async getRecentContacts(limit = 10) { + try { + const contacts = await ContactModel.findWhere( + {}, + { orderBy: "created_at", order: "desc", limit } + ) + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`获取最近联系信息失败:`, error) + throw error + } + } + + /** + * 验证联系信息数据 + * @param {Object} contactData - 联系信息数据 + */ + static validateContactData(contactData) { + if (!contactData.name) { + throw new Error("姓名不能为空") + } + if (!contactData.email) { + throw new Error("邮箱不能为空") + } + if (!contactData.subject) { + throw new Error("主题不能为空") + } + if (!contactData.message) { + throw new Error("消息内容不能为空") + } + + // 邮箱格式验证 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(contactData.email)) { + throw new Error("邮箱格式不正确") + } + + // 姓名长度验证 + if (contactData.name.length > 100) { + throw new Error("姓名不能超过100个字符") + } + + // 主题长度验证 + if (contactData.subject.length > 200) { + throw new Error("主题不能超过200个字符") + } + + // 消息长度验证 + if (contactData.message.length > 2000) { + throw new Error("消息内容不能超过2000个字符") + } + } + + /** + * 验证联系信息更新数据 + * @param {Object} updateData - 更新数据 + */ + static validateContactUpdateData(updateData) { + if (updateData.email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(updateData.email)) { + throw new Error("邮箱格式不正确") + } + } + + if (updateData.name && updateData.name.length > 100) { + throw new Error("姓名不能超过100个字符") + } + + if (updateData.subject && updateData.subject.length > 200) { + throw new Error("主题不能超过200个字符") + } + + if (updateData.message && updateData.message.length > 2000) { + throw new Error("消息内容不能超过2000个字符") + } + + if (updateData.status && !["unread", "read", "replied"].includes(updateData.status)) { + throw new Error("无效的状态值") + } + } + + /** + * 格式化联系信息响应数据 + * @param {Object} contact - 联系信息数据 + * @returns {Object} 格式化后的联系信息数据 + */ + static formatContactResponse(contact) { + return { + ...contact, + // 确保数字字段为数字类型 + id: parseInt(contact.id), + // 格式化日期字段 + created_at: contact.created_at, + updated_at: contact.updated_at + } + } + + /** + * 导出联系信息 + * @param {Object} options - 导出选项 + * @returns {Promise} 导出的联系信息 + */ + static async exportContacts(options = {}) { + try { + const { + status = null, + startDate = null, + endDate = null, + limit = 1000 + } = options + + let where = {} + if (status) where.status = status + + let contacts + if (startDate && endDate) { + contacts = await ContactModel.findByDateRange(startDate, endDate) + } else { + contacts = await ContactModel.findWhere(where, { + orderBy: "created_at", + order: "desc", + limit + }) + } + + return contacts.map(contact => this.formatContactResponse(contact)) + } catch (error) { + logger.error(`导出联系信息失败:`, error) + throw error + } + } + + /** + * 获取联系信息趋势数据 + * @param {number} days - 天数 + * @returns {Promise} 趋势数据 + */ + static async getContactTrends(days = 30) { + try { + const endDate = new Date() + const startDate = new Date() + startDate.setDate(startDate.getDate() - days) + + const contacts = await ContactModel.findByDateRange( + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ) + + // 按日期分组统计 + const trends = {} + contacts.forEach(contact => { + const date = contact.created_at.split('T')[0] + if (!trends[date]) { + trends[date] = { date, count: 0 } + } + trends[date].count++ + }) + + return Object.values(trends).sort((a, b) => a.date.localeCompare(b.date)) + } catch (error) { + logger.error(`获取联系信息趋势数据失败:`, error) + throw error + } + } +} + +export default ContactService +export { ContactService } + diff --git a/src/services/SiteConfigService.js b/src/services/SiteConfigService.js new file mode 100644 index 0000000..cbc7148 --- /dev/null +++ b/src/services/SiteConfigService.js @@ -0,0 +1,513 @@ +import SiteConfigModel from "../db/models/SiteConfigModel.js" +import { logger } from "../logger.js" + +/** + * 站点配置服务类 + * 提供站点配置相关的业务逻辑 + */ +class SiteConfigService { + /** + * 获取配置值 + * @param {string} key - 配置键 + * @param {*} defaultValue - 默认值 + * @returns {Promise<*>} 配置值 + */ + static async get(key, defaultValue = null) { + try { + const value = await SiteConfigModel.get(key) + return value !== null ? value : defaultValue + } catch (error) { + logger.error(`获取配置失败 (${key}):`, error) + throw error + } + } + + /** + * 设置配置值 + * @param {string} key - 配置键 + * @param {*} value - 配置值 + * @returns {Promise} 配置对象 + */ + static async set(key, value) { + try { + // 验证配置键 + this.validateConfigKey(key) + + // 序列化值 + const serializedValue = this.serializeValue(value) + + const config = await SiteConfigModel.set(key, serializedValue) + + logger.info(`配置设置成功: ${key}`) + return this.formatConfigResponse(config) + } catch (error) { + logger.error(`设置配置失败 (${key}):`, error) + throw error + } + } + + /** + * 批量获取配置 + * @param {Array} keys - 配置键数组 + * @returns {Promise} 配置对象 + */ + static async getMany(keys) { + try { + if (!Array.isArray(keys) || keys.length === 0) { + return {} + } + + const configs = await SiteConfigModel.getMany(keys) + + // 反序列化值 + const result = {} + for (const [key, value] of Object.entries(configs)) { + result[key] = this.deserializeValue(value) + } + + return result + } catch (error) { + logger.error(`批量获取配置失败:`, error) + throw error + } + } + + /** + * 获取所有配置 + * @returns {Promise} 所有配置 + */ + static async getAll() { + try { + const configs = await SiteConfigModel.getAll() + + // 反序列化值 + const result = {} + for (const [key, value] of Object.entries(configs)) { + result[key] = this.deserializeValue(value) + } + + return result + } catch (error) { + logger.error(`获取所有配置失败:`, error) + throw error + } + } + + /** + * 批量设置配置 + * @param {Object} configs - 配置对象 + * @returns {Promise} 设置结果 + */ + static async setMany(configs) { + try { + if (!configs || typeof configs !== 'object') { + throw new Error("配置对象不能为空") + } + + const results = [] + for (const [key, value] of Object.entries(configs)) { + try { + this.validateConfigKey(key) + const serializedValue = this.serializeValue(value) + const config = await SiteConfigModel.set(key, serializedValue) + results.push(this.formatConfigResponse(config)) + } catch (error) { + logger.error(`设置配置失败 (${key}):`, error) + results.push({ key, error: error.message }) + } + } + + logger.info(`批量设置配置完成: ${Object.keys(configs).length} 个配置`) + return results + } catch (error) { + logger.error(`批量设置配置失败:`, error) + throw error + } + } + + /** + * 删除配置 + * @param {string} key - 配置键 + * @returns {Promise} 删除结果 + */ + static async delete(key) { + try { + const result = await SiteConfigModel.deleteByKey(key) + + logger.info(`配置删除成功: ${key}`) + return result > 0 + } catch (error) { + logger.error(`删除配置失败 (${key}):`, error) + throw error + } + } + + /** + * 检查配置是否存在 + * @param {string} key - 配置键 + * @returns {Promise} 是否存在 + */ + static async has(key) { + try { + return await SiteConfigModel.hasKey(key) + } catch (error) { + logger.error(`检查配置是否存在失败 (${key}):`, error) + throw error + } + } + + /** + * 获取配置统计 + * @returns {Promise} 统计信息 + */ + static async getStats() { + try { + return await SiteConfigModel.getConfigStats() + } catch (error) { + logger.error(`获取配置统计失败:`, error) + throw error + } + } + + /** + * 获取站点基本信息配置 + * @returns {Promise} 站点基本信息 + */ + static async getSiteInfo() { + try { + const keys = [ + 'site_name', + 'site_description', + 'site_keywords', + 'site_author', + 'site_url', + 'site_logo', + 'site_favicon', + 'site_theme', + 'site_language', + 'site_timezone' + ] + + const configs = await this.getMany(keys) + + return { + name: configs.site_name || '我的网站', + description: configs.site_description || '', + keywords: configs.site_keywords || '', + author: configs.site_author || '', + url: configs.site_url || '', + logo: configs.site_logo || '', + favicon: configs.site_favicon || '', + theme: configs.site_theme || 'default', + language: configs.site_language || 'zh-CN', + timezone: configs.site_timezone || 'Asia/Shanghai' + } + } catch (error) { + logger.error(`获取站点基本信息失败:`, error) + throw error + } + } + + /** + * 设置站点基本信息配置 + * @param {Object} siteInfo - 站点信息 + * @returns {Promise} 设置结果 + */ + static async setSiteInfo(siteInfo) { + try { + const configs = {} + + if (siteInfo.name) configs.site_name = siteInfo.name + if (siteInfo.description) configs.site_description = siteInfo.description + if (siteInfo.keywords) configs.site_keywords = siteInfo.keywords + if (siteInfo.author) configs.site_author = siteInfo.author + if (siteInfo.url) configs.site_url = siteInfo.url + if (siteInfo.logo) configs.site_logo = siteInfo.logo + if (siteInfo.favicon) configs.site_favicon = siteInfo.favicon + if (siteInfo.theme) configs.site_theme = siteInfo.theme + if (siteInfo.language) configs.site_language = siteInfo.language + if (siteInfo.timezone) configs.site_timezone = siteInfo.timezone + + return await this.setMany(configs) + } catch (error) { + logger.error(`设置站点基本信息失败:`, error) + throw error + } + } + + /** + * 获取邮件配置 + * @returns {Promise} 邮件配置 + */ + static async getEmailConfig() { + try { + const keys = [ + 'email_host', + 'email_port', + 'email_secure', + 'email_user', + 'email_password', + 'email_from', + 'email_name' + ] + + const configs = await this.getMany(keys) + + return { + host: configs.email_host || '', + port: parseInt(configs.email_port) || 587, + secure: configs.email_secure === 'true', + user: configs.email_user || '', + password: configs.email_password || '', + from: configs.email_from || '', + name: configs.email_name || '' + } + } catch (error) { + logger.error(`获取邮件配置失败:`, error) + throw error + } + } + + /** + * 设置邮件配置 + * @param {Object} emailConfig - 邮件配置 + * @returns {Promise} 设置结果 + */ + static async setEmailConfig(emailConfig) { + try { + const configs = {} + + if (emailConfig.host) configs.email_host = emailConfig.host + if (emailConfig.port) configs.email_port = emailConfig.port.toString() + if (emailConfig.secure !== undefined) configs.email_secure = emailConfig.secure.toString() + if (emailConfig.user) configs.email_user = emailConfig.user + if (emailConfig.password) configs.email_password = emailConfig.password + if (emailConfig.from) configs.email_from = emailConfig.from + if (emailConfig.name) configs.email_name = emailConfig.name + + return await this.setMany(configs) + } catch (error) { + logger.error(`设置邮件配置失败:`, error) + throw error + } + } + + /** + * 获取系统配置 + * @returns {Promise} 系统配置 + */ + static async getSystemConfig() { + try { + const keys = [ + 'maintenance_mode', + 'registration_enabled', + 'email_verification_required', + 'max_upload_size', + 'allowed_file_types', + 'session_timeout', + 'password_min_length', + 'login_attempts_limit' + ] + + const configs = await this.getMany(keys) + + return { + maintenanceMode: configs.maintenance_mode === 'true', + registrationEnabled: configs.registration_enabled !== 'false', + emailVerificationRequired: configs.email_verification_required === 'true', + maxUploadSize: parseInt(configs.max_upload_size) || 10485760, // 10MB + allowedFileTypes: configs.allowed_file_types ? configs.allowed_file_types.split(',') : ['jpg', 'jpeg', 'png', 'gif', 'pdf'], + sessionTimeout: parseInt(configs.session_timeout) || 3600, // 1小时 + passwordMinLength: parseInt(configs.password_min_length) || 6, + loginAttemptsLimit: parseInt(configs.login_attempts_limit) || 5 + } + } catch (error) { + logger.error(`获取系统配置失败:`, error) + throw error + } + } + + /** + * 设置系统配置 + * @param {Object} systemConfig - 系统配置 + * @returns {Promise} 设置结果 + */ + static async setSystemConfig(systemConfig) { + try { + const configs = {} + + if (systemConfig.maintenanceMode !== undefined) configs.maintenance_mode = systemConfig.maintenanceMode.toString() + if (systemConfig.registrationEnabled !== undefined) configs.registration_enabled = systemConfig.registrationEnabled.toString() + if (systemConfig.emailVerificationRequired !== undefined) configs.email_verification_required = systemConfig.emailVerificationRequired.toString() + if (systemConfig.maxUploadSize) configs.max_upload_size = systemConfig.maxUploadSize.toString() + if (systemConfig.allowedFileTypes) configs.allowed_file_types = Array.isArray(systemConfig.allowedFileTypes) ? systemConfig.allowedFileTypes.join(',') : systemConfig.allowedFileTypes + if (systemConfig.sessionTimeout) configs.session_timeout = systemConfig.sessionTimeout.toString() + if (systemConfig.passwordMinLength) configs.password_min_length = systemConfig.passwordMinLength.toString() + if (systemConfig.loginAttemptsLimit) configs.login_attempts_limit = systemConfig.loginAttemptsLimit.toString() + + return await this.setMany(configs) + } catch (error) { + logger.error(`设置系统配置失败:`, error) + throw error + } + } + + /** + * 重置配置为默认值 + * @param {Array} keys - 要重置的配置键数组(可选,默认重置所有) + * @returns {Promise} 重置结果 + */ + static async resetToDefaults(keys = null) { + try { + const defaultConfigs = { + site_name: '我的网站', + site_description: '欢迎来到我的网站', + site_keywords: '网站,博客,个人网站', + site_author: '网站管理员', + site_url: 'http://localhost:3000', + site_theme: 'default', + site_language: 'zh-CN', + site_timezone: 'Asia/Shanghai', + maintenance_mode: 'false', + registration_enabled: 'true', + email_verification_required: 'false', + max_upload_size: '10485760', + allowed_file_types: 'jpg,jpeg,png,gif,pdf', + session_timeout: '3600', + password_min_length: '6', + login_attempts_limit: '5' + } + + const configsToReset = keys ? + Object.fromEntries(keys.filter(key => defaultConfigs[key]).map(key => [key, defaultConfigs[key]])) : + defaultConfigs + + return await this.setMany(configsToReset) + } catch (error) { + logger.error(`重置配置为默认值失败:`, error) + throw error + } + } + + /** + * 验证配置键 + * @param {string} key - 配置键 + */ + static validateConfigKey(key) { + if (!key || typeof key !== 'string') { + throw new Error("配置键不能为空") + } + + if (key.length > 100) { + throw new Error("配置键长度不能超过100个字符") + } + + if (!/^[a-zA-Z0-9_]+$/.test(key)) { + throw new Error("配置键只能包含字母、数字和下划线") + } + } + + /** + * 序列化值 + * @param {*} value - 要序列化的值 + * @returns {string} 序列化后的字符串 + */ + static serializeValue(value) { + if (value === null || value === undefined) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value.toString() + } + + return JSON.stringify(value) + } + + /** + * 反序列化值 + * @param {string} value - 要反序列化的字符串 + * @returns {*} 反序列化后的值 + */ + static deserializeValue(value) { + if (value === null || value === undefined || value === '') { + return null + } + + // 尝试解析为JSON + try { + return JSON.parse(value) + } catch (e) { + // 如果不是有效的JSON,返回原字符串 + return value + } + } + + /** + * 格式化配置响应数据 + * @param {Object} config - 配置数据 + * @returns {Object} 格式化后的配置数据 + */ + static formatConfigResponse(config) { + return { + ...config, + // 确保数字字段为数字类型 + id: parseInt(config.id), + // 反序列化值 + value: this.deserializeValue(config.value), + // 格式化日期字段 + created_at: config.created_at, + updated_at: config.updated_at + } + } + + /** + * 导出配置 + * @returns {Promise} 导出的配置 + */ + static async exportConfig() { + try { + const configs = await this.getAll() + return { + exported_at: new Date().toISOString(), + configs + } + } catch (error) { + logger.error(`导出配置失败:`, error) + throw error + } + } + + /** + * 导入配置 + * @param {Object} configData - 配置数据 + * @returns {Promise} 导入结果 + */ + static async importConfig(configData) { + try { + if (!configData || !configData.configs) { + throw new Error("无效的配置数据") + } + + const results = await this.setMany(configData.configs) + + logger.info(`配置导入完成: ${Object.keys(configData.configs).length} 个配置`) + return { + success: results.filter(r => !r.error).length, + failed: results.filter(r => r.error).length, + results + } + } catch (error) { + logger.error(`导入配置失败:`, error) + throw error + } + } +} + +export default SiteConfigService +export { SiteConfigService } + diff --git a/src/services/UserService.js b/src/services/UserService.js new file mode 100644 index 0000000..2a0144e --- /dev/null +++ b/src/services/UserService.js @@ -0,0 +1,415 @@ +import UserModel from "../db/models/UserModel.js" +import { logger } from "../logger.js" + +/** + * 用户服务类 + * 提供用户相关的业务逻辑 + */ +class UserService { + /** + * 创建新用户 + * @param {Object} userData - 用户数据 + * @returns {Promise} 创建的用户信息 + */ + static async createUser(userData) { + try { + // 数据验证 + this.validateUserData(userData) + + // 检查用户名和邮箱唯一性 + await this.checkUniqueConstraints(userData) + + // 创建用户 + const user = await UserModel.create(userData) + + logger.info(`用户创建成功: ${user.username} (ID: ${user.id})`) + return this.formatUserResponse(user) + } catch (error) { + logger.error(`创建用户失败:`, error) + throw error + } + } + + /** + * 根据ID获取用户 + * @param {number} id - 用户ID + * @returns {Promise} 用户信息 + */ + static async getUserById(id) { + try { + const user = await UserModel.findById(id) + return user ? this.formatUserResponse(user) : null + } catch (error) { + logger.error(`获取用户失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 根据用户名获取用户 + * @param {string} username - 用户名 + * @returns {Promise} 用户信息 + */ + static async getUserByUsername(username) { + try { + const user = await UserModel.findByUsername(username) + return user ? this.formatUserResponse(user) : null + } catch (error) { + logger.error(`根据用户名获取用户失败 (${username}):`, error) + throw error + } + } + + /** + * 根据邮箱获取用户 + * @param {string} email - 邮箱 + * @returns {Promise} 用户信息 + */ + static async getUserByEmail(email) { + try { + const user = await UserModel.findByEmail(email) + return user ? this.formatUserResponse(user) : null + } catch (error) { + logger.error(`根据邮箱获取用户失败 (${email}):`, error) + throw error + } + } + + /** + * 更新用户信息 + * @param {number} id - 用户ID + * @param {Object} updateData - 更新数据 + * @returns {Promise} 更新后的用户信息 + */ + static async updateUser(id, updateData) { + try { + // 验证用户是否存在 + const existingUser = await UserModel.findById(id) + if (!existingUser) { + throw new Error("用户不存在") + } + + // 数据验证 + this.validateUserUpdateData(updateData) + + // 检查唯一性约束 + await this.checkUniqueConstraintsForUpdate(id, updateData) + + // 更新用户 + const user = await UserModel.update(id, updateData) + + logger.info(`用户更新成功: ${user.username} (ID: ${id})`) + return this.formatUserResponse(user) + } catch (error) { + logger.error(`更新用户失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 删除用户 + * @param {number} id - 用户ID + * @returns {Promise} 删除结果 + */ + static async deleteUser(id) { + try { + const user = await UserModel.findById(id) + if (!user) { + throw new Error("用户不存在") + } + + const result = await UserModel.delete(id) + + logger.info(`用户删除成功: ${user.username} (ID: ${id})`) + return result > 0 + } catch (error) { + logger.error(`删除用户失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 获取用户列表 + * @param {Object} options - 查询选项 + * @returns {Promise} 用户列表和分页信息 + */ + static async getUserList(options = {}) { + try { + const { + page = 1, + limit = 10, + search = "", + role = null, + status = null, + orderBy = "created_at", + order = "desc" + } = options + + const where = {} + if (role) where.role = role + if (status) where.status = status + + const result = await UserModel.paginate({ + page, + limit, + where, + search, + searchFields: UserModel.searchableFields, + orderBy, + order + }) + + return { + users: result.data.map(user => this.formatUserResponse(user)), + pagination: result.pagination + } + } catch (error) { + logger.error(`获取用户列表失败:`, error) + throw error + } + } + + /** + * 激活用户 + * @param {number} id - 用户ID + * @returns {Promise} 更新后的用户信息 + */ + static async activateUser(id) { + try { + const user = await UserModel.activate(id) + logger.info(`用户激活成功: ${user.username} (ID: ${id})`) + return this.formatUserResponse(user) + } catch (error) { + logger.error(`激活用户失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 停用用户 + * @param {number} id - 用户ID + * @returns {Promise} 更新后的用户信息 + */ + static async deactivateUser(id) { + try { + const user = await UserModel.deactivate(id) + logger.info(`用户停用成功: ${user.username} (ID: ${id})`) + return this.formatUserResponse(user) + } catch (error) { + logger.error(`停用用户失败 (ID: ${id}):`, error) + throw error + } + } + + /** + * 根据角色获取用户 + * @param {string} role - 角色 + * @returns {Promise} 用户列表 + */ + static async getUsersByRole(role) { + try { + const users = await UserModel.findByRole(role) + return users.map(user => this.formatUserResponse(user)) + } catch (error) { + logger.error(`根据角色获取用户失败 (${role}):`, error) + throw error + } + } + + /** + * 获取用户统计信息 + * @returns {Promise} 统计信息 + */ + static async getUserStats() { + try { + return await UserModel.getUserStats() + } catch (error) { + logger.error(`获取用户统计失败:`, error) + throw error + } + } + + /** + * 验证用户数据 + * @param {Object} userData - 用户数据 + */ + static validateUserData(userData) { + if (!userData.username) { + throw new Error("用户名不能为空") + } + if (!userData.email) { + throw new Error("邮箱不能为空") + } + if (!userData.password) { + throw new Error("密码不能为空") + } + + // 邮箱格式验证 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(userData.email)) { + throw new Error("邮箱格式不正确") + } + + // 用户名长度验证 + if (userData.username.length < 3 || userData.username.length > 20) { + throw new Error("用户名长度必须在3-20个字符之间") + } + + // 密码强度验证 + if (userData.password.length < 6) { + throw new Error("密码长度不能少于6个字符") + } + } + + /** + * 验证用户更新数据 + * @param {Object} updateData - 更新数据 + */ + static validateUserUpdateData(updateData) { + if (updateData.email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(updateData.email)) { + throw new Error("邮箱格式不正确") + } + } + + if (updateData.username) { + if (updateData.username.length < 3 || updateData.username.length > 20) { + throw new Error("用户名长度必须在3-20个字符之间") + } + } + + if (updateData.password && updateData.password.length < 6) { + throw new Error("密码长度不能少于6个字符") + } + } + + /** + * 检查唯一性约束 + * @param {Object} userData - 用户数据 + */ + static async checkUniqueConstraints(userData) { + if (userData.username) { + const existingUser = await UserModel.findByUsername(userData.username) + if (existingUser) { + throw new Error("用户名已存在") + } + } + + if (userData.email) { + const existingEmail = await UserModel.findByEmail(userData.email) + if (existingEmail) { + throw new Error("邮箱已存在") + } + } + } + + /** + * 检查更新时的唯一性约束 + * @param {number} id - 用户ID + * @param {Object} updateData - 更新数据 + */ + static async checkUniqueConstraintsForUpdate(id, updateData) { + if (updateData.username) { + const existingUser = await UserModel.findByUsername(updateData.username) + if (existingUser && existingUser.id !== parseInt(id)) { + throw new Error("用户名已存在") + } + } + + if (updateData.email) { + const existingEmail = await UserModel.findByEmail(updateData.email) + if (existingEmail && existingEmail.id !== parseInt(id)) { + throw new Error("邮箱已存在") + } + } + } + + /** + * 格式化用户响应数据 + * @param {Object} user - 用户数据 + * @returns {Object} 格式化后的用户数据 + */ + static formatUserResponse(user) { + const { password, ...userWithoutPassword } = user + return userWithoutPassword + } + + /** + * 批量创建用户 + * @param {Array} usersData - 用户数据数组 + * @returns {Promise} 创建结果 + */ + static async createUsersBatch(usersData) { + try { + const results = [] + const errors = [] + + for (let i = 0; i < usersData.length; i++) { + try { + const userData = usersData[i] + this.validateUserData(userData) + await this.checkUniqueConstraints(userData) + + const user = await UserModel.create(userData) + results.push(this.formatUserResponse(user)) + } catch (error) { + errors.push({ + index: i, + data: usersData[i], + error: error.message + }) + } + } + + return { + success: results, + errors, + summary: { + total: usersData.length, + success: results.length, + failed: errors.length + } + } + } catch (error) { + logger.error(`批量创建用户失败:`, error) + throw error + } + } + + /** + * 搜索用户 + * @param {string} keyword - 搜索关键词 + * @param {Object} options - 搜索选项 + * @returns {Promise} 搜索结果 + */ + static async searchUsers(keyword, options = {}) { + try { + const { + limit = 20, + role = null, + status = null + } = options + + const where = {} + if (role) where.role = role + if (status) where.status = status + + const users = await UserModel.findWhere(where, { + search: keyword, + searchFields: UserModel.searchableFields, + limit, + orderBy: "created_at", + order: "desc" + }) + + return users.map(user => this.formatUserResponse(user)) + } catch (error) { + logger.error(`搜索用户失败:`, error) + throw error + } + } +} + +export default UserService +export { UserService } diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 0000000..357fa27 --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,84 @@ +/** + * 服务层统一导出 + * 提供所有业务服务的统一访问入口 + */ + +import UserService from "./UserService.js" +import ArticleService from "./ArticleService.js" +import BookmarkService from "./BookmarkService.js" +import ContactService from "./ContactService.js" +import SiteConfigService from "./SiteConfigService.js" +import JobService from "./JobService.js" + +/** + * 服务层统一管理类 + * 提供所有业务服务的统一访问和管理 + */ +class ServiceManager { + constructor() { + this.services = { + user: UserService, + article: ArticleService, + bookmark: BookmarkService, + contact: ContactService, + siteConfig: SiteConfigService, + job: JobService + } + } + + /** + * 获取指定服务 + * @param {string} serviceName - 服务名称 + * @returns {Object} 服务实例 + */ + getService(serviceName) { + const service = this.services[serviceName] + if (!service) { + throw new Error(`服务 ${serviceName} 不存在`) + } + return service + } + + /** + * 获取所有服务列表 + * @returns {Array} 服务名称列表 + */ + getServiceList() { + return Object.keys(this.services) + } + + /** + * 检查服务是否存在 + * @param {string} serviceName - 服务名称 + * @returns {boolean} 是否存在 + */ + hasService(serviceName) { + return serviceName in this.services + } +} + +// 创建全局服务管理器实例 +const serviceManager = new ServiceManager() + +// 导出所有服务 +export { + UserService, + ArticleService, + BookmarkService, + ContactService, + SiteConfigService, + JobService, + ServiceManager +} + +// 导出服务管理器实例 +export default serviceManager + +// 便捷访问方法 +export const getUserService = () => serviceManager.getService('user') +export const getArticleService = () => serviceManager.getService('article') +export const getBookmarkService = () => serviceManager.getService('bookmark') +export const getContactService = () => serviceManager.getService('contact') +export const getSiteConfigService = () => serviceManager.getService('siteConfig') +export const getJobService = () => serviceManager.getService('job') + diff --git a/src/utils/ForRegister.js b/src/utils/ForRegister.js index 6227080..578dbb5 100644 --- a/src/utils/ForRegister.js +++ b/src/utils/ForRegister.js @@ -21,10 +21,10 @@ if (import.meta.env.PROD) { * @param {string} prefix - 路由前缀 * @param {Set} [manualControllers] - 可选,手动传入已注册 controller 文件名集合,优先于自动扫描 */ -export function autoRegisterControllers(app, controllersDir) { +export async function autoRegisterControllers(app, controllersDir) { let allRouter = [] - - function scan(dir, routePrefix = "") { + + async function scan(dir, routePrefix = "") { try { for (const file of fs.readdirSync(dir)) { const fullPath = path.join(dir, file) @@ -32,7 +32,7 @@ export function autoRegisterControllers(app, controllersDir) { if (stat.isDirectory()) { if (!file.startsWith("_")) { - scan(fullPath, routePrefix + "/" + file) + await scan(fullPath, routePrefix + "/" + file) } } else if (file.endsWith("Controller.js") && !file.startsWith("_")) { try { @@ -50,7 +50,7 @@ export function autoRegisterControllers(app, controllersDir) { } // 使用动态导入ES模块 - const controllerModule = require(fullPath) + const controllerModule = await import(fullPath) const controller = controllerModule.default || controllerModule if (!controller) { @@ -107,6 +107,7 @@ export function autoRegisterControllers(app, controllersDir) { } } catch (importError) { logger.error(`[控制器注册] ❌ ${file} - 模块导入失败: ${importError.message}`) + logger.error(importError) } } } @@ -116,7 +117,7 @@ export function autoRegisterControllers(app, controllersDir) { } try { - scan(controllersDir) + await scan(controllersDir) if (allRouter.length === 0) { logger.warn("[路由注册] ⚠️ 未发现任何可注册的控制器") diff --git a/src/utils/error/AuthError.js b/src/utils/error/AuthError.js new file mode 100644 index 0000000..e3a8bd1 --- /dev/null +++ b/src/utils/error/AuthError.js @@ -0,0 +1,10 @@ +import app from "@/global.js" +import BaseError from "./BaseError.js" + +export default class AuthError extends BaseError { + constructor(message, status = AuthError.ERR_CODE.UNAUTHORIZED) { + super(message, status) + this.name = "AuthError" + this.ctx = app.currentContext + } +} diff --git a/src/utils/error/CommonError.js b/src/utils/error/CommonError.js index 2fdf24d..42ea0c8 100644 --- a/src/utils/error/CommonError.js +++ b/src/utils/error/CommonError.js @@ -1,8 +1,10 @@ +import app from "@/global.js" import BaseError from "./BaseError.js" export default class CommonError extends BaseError { - constructor(message, status = CommonError.BAD_REQUEST) { + constructor(message, status = CommonError.ERR_CODE.BAD_REQUEST) { super(message, status) this.name = "CommonError" + this.ctx = app.currentContext } } diff --git a/src/utils/router.js b/src/utils/router.js index b6b7235..6ed3dcc 100644 --- a/src/utils/router.js +++ b/src/utils/router.js @@ -1,7 +1,31 @@ import { match } from 'path-to-regexp'; import compose from 'koa-compose'; -import RouteAuth from './router/RouteAuth.js'; import routeCache from './cache/RouteCache.js'; +import AuthError from './error/AuthError.js'; +import CommonError from './error/CommonError.js'; + +function RouteAuth(options = {}) { + const { auth = true } = options + return async (ctx, next) => { + // 当 auth 为 false 时,已登录用户不能访问 + if (auth === false) { + if (ctx.state.user) { + throw new CommonError("该接口不能登录查看") + } + } + + // 当 auth 为 true 时,必须登录才能访问 + if (auth === true) { + if (!ctx.state.user) { + throw new AuthError("该接口必须登录查看") + } + } + + // 其他自定义模式(如角色检查等) + return await next() + } +} + class Router { /** @@ -85,14 +109,14 @@ class Router { middleware() { return async (ctx, next) => { const { method, path } = ctx; - + // 尝试从缓存获取路由匹配结果 let route = routeCache.getRouteMatch(method, path); - + if (!route) { // 缓存未命中,执行路由匹配 route = this._matchRoute(method.toLowerCase(), path); - + // 将匹配结果存入缓存 if (route) { routeCache.setRouteMatch(method, path, route); @@ -109,17 +133,17 @@ class Router { if (route.meta && route.meta.auth !== undefined) { isAuth = route.meta.auth; } - + // 尝试从缓存获取组合中间件 const cacheKey = { auth: isAuth, middlewares: this.middlewares.length }; let composed = routeCache.getMiddlewareComposition(this.middlewares, cacheKey); - + if (!composed) { // 缓存未命中,重新组合中间件 middlewares.push(RouteAuth({ auth: isAuth })); middlewares.push(route.handler); composed = compose(middlewares); - + // 将组合结果存入缓存 routeCache.setMiddlewareComposition(this.middlewares, cacheKey, composed); } else { @@ -129,7 +153,7 @@ class Router { finalMiddlewares.push(route.handler); composed = compose(finalMiddlewares); } - + await composed(ctx, next); } else { // 如果没有匹配到路由,直接调用 next diff --git a/src/utils/router/RouteAuth.js b/src/utils/router/RouteAuth.js index 00703fb..691952a 100644 --- a/src/utils/router/RouteAuth.js +++ b/src/utils/router/RouteAuth.js @@ -1,50 +1,26 @@ -import jwt from "jsonwebtoken" +import CommonError from '../error/CommonError' +import AuthError from '../error/AuthError' -const JWT_SECRET = process.env.JWT_SECRET - -/** - * 路由级权限中间件 - * 支持: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 {} + // 当 auth 为 false 时,已登录用户不能访问 + if (auth === false) { + if (ctx.state.user) { + throw new CommonError("该接口不能登录查看") } + return await next() } - if (auth === "try") { - return next() - } - + // 当 auth 为 true 时,必须登录才能访问 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 + throw new AuthError("该接口必须登录查看") } - return next() + return await next() } - // 其他自定义模式 - return next() + // 其他自定义模式(如角色检查等) + return await next() } } - -function getToken(ctx) { - // 只支持 Authorization: Bearer xxx - return ctx.headers["authorization"]?.replace(/^Bearer\s/i, "") -} diff --git a/src/views/htmx/footer.pug b/src/views/htmx/footer.pug index 42f27b3..3b836bc 100644 --- a/src/views/htmx/footer.pug +++ b/src/views/htmx/footer.pug @@ -1,6 +1,6 @@ .footer-panel .footer-content - p.back-to-top © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 + p.back-to-top © 2023-#{new Date().getFullYear()} #{siteConfig.site_author}. 保留所有权利。 ul.footer-links li diff --git a/src/views/layouts/empty.pug b/src/views/layouts/empty.pug index 2a97747..d3a915e 100644 --- a/src/views/layouts/empty.pug +++ b/src/views/layouts/empty.pug @@ -11,8 +11,7 @@ block $$content .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} + a(href="/" class="text-[20px]") #{siteConfig.site_title} // 桌面端菜单 .left.menu.desktop-only a.menu-item( @@ -28,10 +27,12 @@ block $$content 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="/profile") + span 欢迎您, + span.font-semibold #{user.name || user.username} a.menu-item(href="/notice") .fe--notice-active + a.menu-item(hx-post="/logout") 退出 // 移动端:汉堡按钮 button.menu-toggle(type="button" aria-label="打开菜单") span.bar @@ -47,9 +48,11 @@ block $$content a.menu-item(href="/register") 注册 else .right.menu - a.menu-item(hx-post="/logout") 退出 - a.menu-item() 欢迎您 , #{$user.name} + a.menu-item(href="/profile") + span 欢迎您, + span.font-semibold #{user.name || user.username} a.menu-item(href="/notice" class="fe--notice-active") 公告 + a.menu-item(hx-post="/logout") 退出 .page-layout .page.container block pageContent @@ -59,7 +62,7 @@ block $$content .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} + h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") #{siteConfig.site_title} p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 .footer-section @@ -103,7 +106,7 @@ block $$content .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}. 保留所有权利。 + | © 2023-#{new Date().getFullYear()} #{siteConfig.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订阅 @@ -119,4 +122,4 @@ block $$scripts navbar.classList.toggle('open'); }); } - })(); + })(); \ 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 d491eff..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.excerpt - .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.excerpt - h3.text-lg.font-semibold.mb-2 摘要 - p.text-gray-600= article.excerpt - - 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/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 3.pug b/src/views/page/index/index copy 3.pug deleted file mode 100644 index a0a5446..0000000 --- a/src/views/page/index/index copy 3.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.excerpt} - 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/index copy.pug b/src/views/page/index/index copy.pug index 6c53ce1..b59f6ea 100644 --- a/src/views/page/index/index copy.pug +++ b/src/views/page/index/index copy.pug @@ -1,17 +1,22 @@ -extends /layouts/pure.pug +extends /layouts/empty.pug block pageHead - +css("css/page/index.css") + style + :scss(includePaths=["D:/@code/demo/koa3-demo/src/views/page/index", "D:/@code/demo/koa3-demo/src/views"]) + //- process.env.SASS_PATH = "D:/@code/demo/koa3-demo/src/views/page/index" + @import "./index.scss"; + $color: red; + * { + color: $color; + } + 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 + :markdown-it(linkify langPrefix='highlight-') + # Markdown + + Markdown document with http://links.com and + + ```js + var codeBlocks; + ``` \ No newline at end of file diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug index 56f2b63..37b6843 100644 --- a/src/views/page/index/index.pug +++ b/src/views/page/index/index.pug @@ -1 +1,19 @@ -div sada \ No newline at end of file +extends /layouts/empty.pug + +block pageHead + style + + + +block pageContent + :my-own-filter(addStart addEnd) + Filter + Body + :markdown-it(linkify langPrefix='highlight-') + # Markdown + + Markdown document with http://links.com and + + ```js + var codeBlocks; + ``` \ No newline at end of file diff --git a/src/views/page/index/index.scss b/src/views/page/index/index.scss new file mode 100644 index 0000000..0ac4c77 --- /dev/null +++ b/src/views/page/index/index.scss @@ -0,0 +1,146 @@ +/* 首页样式 */ + +.hero-section { + position: relative; + overflow: hidden; +} + +.hero-section::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/hero-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.1; + z-index: 0; +} + +.hero-content { + position: relative; + z-index: 1; +} + +.feature-card { + transition: all 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); +} + +.feature-card .material-symbols-light--article, +.feature-card .material-symbols-light--bookmark, +.feature-card .material-symbols-light--person { + transition: all 0.3s ease; +} + +.feature-card:hover .material-symbols-light--article, +.feature-card:hover .material-symbols-light--bookmark, +.feature-card:hover .material-symbols-light--person { + transform: scale(1.1); +} + +.stats-section { + position: relative; +} + +.stats-section::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/stats-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.05; + z-index: 0; +} + +.stat-item { + transition: all 0.3s ease; +} + +.stat-item:hover { + transform: scale(1.05); +} + +.user-dashboard { + position: relative; +} + +.user-dashboard::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('/images/dashboard-bg.svg') no-repeat center center; + background-size: cover; + opacity: 0.03; + z-index: 0; +} + +.avatar { + transition: all 0.3s ease; +} + +.avatar:hover { + transform: scale(1.05); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .hero-section { + padding: 4rem 0; + } + + .hero-content h1 { + font-size: 2.5rem; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .user-info { + text-align: center; + margin-bottom: 1.5rem; + } + + .user-actions { + justify-content: center; + } +} + +@media (max-width: 480px) { + .hero-content h1 { + font-size: 2rem; + } + + .hero-content p { + font-size: 1rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .hero-actions { + flex-direction: column; + gap: 1rem; + } + + .hero-actions a { + width: 100%; + text-align: center; + } +} \ 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 d5453c2..0000000 --- a/src/views/page/profile/index.pug +++ /dev/null @@ -1,752 +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); - } - - // 头像上传相关样式 - .avatar-upload-section { - position: relative; - margin-bottom: 20px; - } - - .avatar-preview { - width: 120px; - height: 120px; - border-radius: 50%; - border: 3px dashed #d1d5db; - background: #f9fafb; - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto 16px; - cursor: pointer; - transition: all 0.3s ease; - position: relative; - overflow: hidden; - } - - .avatar-preview:hover { - border-color: #667eea; - background: #f0f4ff; - } - - .avatar-preview img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 50%; - } - - .avatar-preview .avatar-upload-placeholder { - text-align: center; - color: #9ca3af; - font-size: 0.875rem; - } - - .avatar-preview .upload-icon { - font-size: 2rem; - margin-bottom: 8px; - display: block; - } - - .avatar-preview .upload-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0,0,0,0.6); - color: white; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s ease; - border-radius: 50%; - font-size: 0.875rem; - text-align: center; - } - - .avatar-preview:hover .upload-overlay { - opacity: 1; - } - - .file-input-hidden { - position: absolute; - opacity: 0; - width: 0; - height: 0; - overflow: hidden; - } - - .avatar-upload-info { - text-align: center; - font-size: 0.8rem; - color: #6b7280; - margin-bottom: 16px; - } - - .upload-progress { - width: 100%; - height: 4px; - background: #e5e7eb; - border-radius: 2px; - overflow: hidden; - margin-top: 8px; - display: none; - } - - .upload-progress-bar { - height: 100%; - background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); - width: 0; - transition: width 0.3s ease; - } - - .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 - .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 头像设置 - .avatar-upload-section - .avatar-preview(onclick="document.getElementById('avatarFile').click()") - if user.avatar - img(src=user.avatar alt="当前头像" id="avatarPreviewImg") - .upload-overlay - div - div 📷 - div 点击更换头像 - else - .avatar-upload-placeholder - span.upload-icon 📷 - div 点击上传头像 - - input.file-input-hidden#avatarFile( - type="file" - name="avatarFile" - accept="image/*" - onchange="handleAvatarSelect(this)" - ) - - .avatar-upload-info - | 支持 JPG、PNG、GIF 格式,文件大小不超过 5MB - - .upload-progress#uploadProgress - .upload-progress-bar#uploadProgressBar - - .form-group - label.form-label(for="avatar") 头像URL(可选) - input.form-input#avatar( - type="text" - name="avatar" - value=user.avatar || '' - placeholder="或直接输入头像图片链接" - ) - .error-message#avatar-error - - .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