diff --git a/bun.lockb b/bun.lockb index 3adb219..3882124 100644 Binary files a/bun.lockb and b/bun.lockb differ 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