Browse Source

更新配置和中间件,优化错误处理及路由权限控制

- 修改 jsconfig.json,更新模块和目标版本为 ESNext 和 ES2020,增加模块解析和类型检查选项
- 在 knexfile.mjs 中优化 SQLite 性能设置,确保连接创建后只调用一次 done()
- 更新公共样式,增强响应式设计,改善用户界面体验
- 在 logger.js 中移除错误日志记录,简化日志配置
- 在 main.js 中重构插件注册逻辑,确保中间件异步加载
- 在 BaseController.js 中新增用户登录状态检查和获取用户ID的方法
- 在 JobController.js 中为路由添加认证中间件
- 在 CommonController.js 中提供全局数据,优化首页渲染逻辑
- 在 install.js 中增强中间件功能,提供全局配置数据
- 在 Auth 中间件中优化用户验证逻辑,确保状态管理一致性
- 在 errorHandler 中增强错误响应格式,提升开发环境调试体验
- 更新路由和视图文件,确保数据传递和渲染逻辑一致性
pure
谢亚昕 3 months ago
parent
commit
4f34ac6988
  1. BIN
      bun.lockb
  2. 10
      jsconfig.json
  3. 28
      knexfile.mjs
  4. 2
      package.json
  5. 173
      public/css/page/index.css
  6. 3
      public/images/dashboard-bg.svg
  7. 3
      public/images/hero-bg.svg
  8. 3
      public/images/stats-bg.svg
  9. 22
      src/base/BaseController.js
  10. 3
      src/controllers/Api/JobController.js
  11. 38
      src/controllers/Page/CommonController.js
  12. 104
      src/db/index.js
  13. 2
      src/db/models/SiteConfigModel.js
  14. 5
      src/logger.js
  15. 15
      src/main.js
  16. 8
      src/middlewares/Auth/index.js
  17. 31
      src/middlewares/Views/index.js
  18. 91
      src/middlewares/errorHandler/index.js
  19. 42
      src/middlewares/install.js
  20. 563
      src/services/ArticleService.js
  21. 492
      src/services/BookmarkService.js
  22. 511
      src/services/ContactService.js
  23. 513
      src/services/SiteConfigService.js
  24. 415
      src/services/UserService.js
  25. 84
      src/services/index.js
  26. 11
      src/utils/ForRegister.js
  27. 10
      src/utils/error/AuthError.js
  28. 4
      src/utils/error/CommonError.js
  29. 26
      src/utils/router.js
  30. 48
      src/utils/router/RouteAuth.js
  31. 2
      src/views/htmx/footer.pug
  32. 19
      src/views/layouts/empty.pug
  33. 20
      src/views/page/about/index.pug
  34. 70
      src/views/page/articles/article.pug
  35. 29
      src/views/page/articles/category.pug
  36. 134
      src/views/page/articles/index.pug
  37. 34
      src/views/page/articles/search.pug
  38. 32
      src/views/page/articles/tag.pug
  39. 10
      src/views/page/index copy/index.pug
  40. 11
      src/views/page/index/index copy 2.pug
  41. 69
      src/views/page/index/index copy 3.pug
  42. 31
      src/views/page/index/index copy.pug
  43. 20
      src/views/page/index/index.pug
  44. 146
      src/views/page/index/index.scss
  45. 19
      src/views/page/login/index.pug
  46. 7
      src/views/page/notice/index.pug
  47. 752
      src/views/page/profile/index.pug
  48. 119
      src/views/page/register/index.pug

BIN
bun.lockb

Binary file not shown.

10
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/**/*",

28
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()
},
},
},

2
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",

173
public/css/page/index.css

@ -1,69 +1,146 @@
.list {
display: flex;
gap: 15px;
flex-wrap: wrap;
/* 首页样式 */
&.blog {
.hero-section {
position: relative;
overflow: hidden;
}
>* {
width: calc(25% - 15px * 3 / 4);
}
.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;
}
/* ≥1024px 默认4列;介于768px-1023px 显示3列 */
@media (max-width: 1023px) {
>* {
width: calc(33.3333% - 15px * 2 / 3);
}
}
.hero-content {
position: relative;
z-index: 1;
}
/* 介于640px-767px 显示2列 */
@media (max-width: 767px) {
>* {
width: calc(50% - 15px * 1 / 2);
}
.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;
}
/* <640px 显示1列,并优化间距与字号 */
@media (max-width: 639px) {
gap: 12px;
.hero-content h1 {
font-size: 2.5rem;
}
>* {
width: 100%;
.features-grid {
grid-template-columns: 1fr;
}
.article-card {
padding: 14px;
.stats-grid {
grid-template-columns: 1fr 1fr;
}
.article-title {
font-size: 16px;
.user-info {
text-align: center;
margin-bottom: 1.5rem;
}
.article-meta {
font-size: 12px;
.user-actions {
justify-content: center;
}
}
.article-desc {
font-size: 14px;
@media (max-width: 480px) {
.hero-content h1 {
font-size: 2rem;
}
.hero-content p {
font-size: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
.list a:hover {
text-decoration: underline;
}
.hero-actions {
flex-direction: column;
gap: 1rem;
}
.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-actions a {
width: 100%;
text-align: center;
}
}

3
public/images/dashboard-bg.svg

@ -0,0 +1,3 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<polygon points="50,1 99,99 1,99" fill="#8b5cf6" opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

3
public/images/hero-bg.svg

@ -0,0 +1,3 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="1" fill="#3b82f6" opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 135 B

3
public/images/stats-bg.svg

@ -0,0 +1,3 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect width="1" height="1" fill="#10b981" opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 132 B

22
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("无权限操作此资源")
}
}

3
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))

38
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
}

104
src/db/index.js

@ -122,7 +122,8 @@ export const DbQueryCache = {
// QueryBuilder 扩展
// 1) cache(ttlMs?): 读取缓存,不存在则执行并写入
buildKnex.QueryBuilder.extend("cache", async function (ttlMs) {
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)) {
@ -131,99 +132,88 @@ buildKnex.QueryBuilder.extend("cache", async function (ttlMs) {
const data = await this
queryCache.set(key, { value: data, expiresAt: computeExpiresAt(ttlMs) })
return data
})
})
// 2) cacheAs(customKey): 设置自定义 key
buildKnex.QueryBuilder.extend("cacheAs", function (customKey) {
// 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) {
// 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 () {
// 4) cacheGet(): 仅从缓存读取当前查询 key 的值
buildKnex.QueryBuilder.extend("cacheGet", function () {
const key = getCacheKeyForBuilder(this)
const entry = queryCache.get(key)
if (!entry || isExpired(entry)) return undefined
return entry.value
})
})
// 5) cacheInvalidate(): 使当前查询 key 的缓存失效
buildKnex.QueryBuilder.extend("cacheInvalidate", function () {
// 5) cacheInvalidate(): 使当前查询 key 的缓存失效
buildKnex.QueryBuilder.extend("cacheInvalidate", function () {
const key = getCacheKeyForBuilder(this)
queryCache.delete(key)
return this
})
})
// 6) cacheInvalidateByPrefix(prefix): 按前缀清理
buildKnex.QueryBuilder.extend("cacheInvalidateByPrefix", function (prefix) {
// 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
})
})
// 7) 数据变更时自动清理相关缓存
buildKnex.QueryBuilder.extend("invalidateCache", function() {
// 7) 数据变更时自动清理相关缓存
buildKnex.QueryBuilder.extend("invalidateCache", function() {
const tableName = this._single?.table
if (tableName) {
DbQueryCache.invalidateByTable(tableName)
logger.debug(`清理表 ${tableName} 的缓存`)
}
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) {
// 使用更安全的方式扩展 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') {
// 在操作完成后清理缓存
result.then(() => {
DbQueryCache.invalidateByTable(tableName)
const originalThen = result.then;
result.then = function(...thenArgs) {
const promise = originalThen.apply(this, thenArgs);
promise.then(() => {
DbQueryCache.invalidateByTable(tableName);
}).catch(() => {
// 即使失败也清理缓存,保证一致性
DbQueryCache.invalidateByTable(tableName)
})
DbQueryCache.invalidateByTable(tableName);
});
return promise;
};
}
return result
}
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)
})
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])

2
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

5
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');

15
src/main.js

@ -9,12 +9,14 @@ import os from "os"
// 应用插件与自动路由
import LoadMiddlewares from "./middlewares/install.js"
// 注册插件
LoadMiddlewares(app)
const PORT = process.env.PORT || 3000;
const PORT = process.env.PORT || 3000
; (async () => {
const server = app.listen(PORT, () => {
// 注册插件
await LoadMiddlewares(app)
const server = app.listen(PORT, () => {
const port = server.address().port
// 获取本地 IP
const getLocalIP = () => {
@ -36,6 +38,7 @@ const server = app.listen(PORT, () => {
logger.trace(` `)
logger.trace(` 服务启动时间: ${new Date().toLocaleString()} `)
logger.trace(`──────────────────────────────────────────────────────\n`)
})
})
export default app
})()

8
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()

31
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)

91
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<void>}
*/
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
)
}
}
}

42
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()

563
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<Object>} 创建的文章信息
*/
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<Object|null>} 文章信息
*/
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<Object|null>} 文章信息
*/
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<Object>} 更新后的文章信息
*/
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<boolean>} 删除结果
*/
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<Object>} 发布后的文章信息
*/
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<Object>} 取消发布后的文章信息
*/
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<Object>} 文章列表和分页信息
*/
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<Object>} 文章列表和分页信息
*/
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<Object>} 文章列表和分页信息
*/
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<Array>} 文章列表
*/
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<Array>} 文章列表
*/
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<Array>} 搜索结果
*/
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<Array>} 最新文章列表
*/
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<Array>} 热门文章列表
*/
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<Array>} 精选文章列表
*/
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<Array>} 相关文章列表
*/
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<Object>} 统计信息
*/
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<number>} 更新数量
*/
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<Array>} 分类统计
*/
static async getCategoryStats() {
try {
return await ArticleModel.getArticleCountByCategory()
} catch (error) {
logger.error(`获取文章分类统计失败:`, error)
throw error
}
}
/**
* 获取文章标签列表
* @returns {Promise<Array>} 标签列表
*/
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 }

492
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<Object>} 创建的书签信息
*/
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<Object|null>} 书签信息
*/
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<Object>} 更新后的书签信息
*/
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<boolean>} 删除结果
*/
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<Object>} 书签列表和分页信息
*/
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<Object>} 书签列表和分页信息
*/
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<Array>} 书签列表
*/
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<Array>} 热门书签列表
*/
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<Array>} 搜索结果
*/
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<boolean>} 是否存在
*/
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<Object>} 统计信息
*/
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<number>} 删除数量
*/
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<Object>} 创建结果
*/
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<Array>} 分类统计
*/
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<Object>} 导入结果
*/
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 }

511
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<Object>} 创建的联系信息
*/
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<Object|null>} 联系信息
*/
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<Object>} 更新后的联系信息
*/
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<boolean>} 删除结果
*/
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<Object>} 联系信息列表和分页信息
*/
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<Array>} 联系信息列表
*/
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<Array>} 联系信息列表
*/
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<Array>} 联系信息列表
*/
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<Object>} 更新后的联系信息
*/
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<Object>} 更新后的联系信息
*/
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<number>} 更新数量
*/
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<number>} 删除数量
*/
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<Object>} 统计信息
*/
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<number>} 今日新联系数量
*/
static async getTodayContactCount() {
try {
return await ContactModel.getTodayCount()
} catch (error) {
logger.error(`获取今日新联系数量失败:`, error)
throw error
}
}
/**
* 搜索联系信息
* @param {string} keyword - 搜索关键词
* @param {Object} options - 搜索选项
* @returns {Promise<Array>} 搜索结果
*/
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<number>} 未读数量
*/
static async getUnreadCount() {
try {
return await ContactModel.count({ status: "unread" })
} catch (error) {
logger.error(`获取未读联系信息数量失败:`, error)
throw error
}
}
/**
* 获取最近联系信息
* @param {number} limit - 数量限制
* @returns {Promise<Array>} 最近联系信息列表
*/
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<Array>} 导出的联系信息
*/
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<Array>} 趋势数据
*/
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 }

513
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<Object>} 配置对象
*/
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<Object>} 配置对象
*/
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<Object>} 所有配置
*/
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<Array>} 设置结果
*/
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<boolean>} 删除结果
*/
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<boolean>} 是否存在
*/
static async has(key) {
try {
return await SiteConfigModel.hasKey(key)
} catch (error) {
logger.error(`检查配置是否存在失败 (${key}):`, error)
throw error
}
}
/**
* 获取配置统计
* @returns {Promise<Object>} 统计信息
*/
static async getStats() {
try {
return await SiteConfigModel.getConfigStats()
} catch (error) {
logger.error(`获取配置统计失败:`, error)
throw error
}
}
/**
* 获取站点基本信息配置
* @returns {Promise<Object>} 站点基本信息
*/
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<Object>} 设置结果
*/
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<Object>} 邮件配置
*/
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<Object>} 设置结果
*/
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<Object>} 系统配置
*/
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<Object>} 设置结果
*/
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<Object>} 重置结果
*/
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<Object>} 导出的配置
*/
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<Object>} 导入结果
*/
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 }

415
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<Object>} 创建的用户信息
*/
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<Object|null>} 用户信息
*/
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<Object|null>} 用户信息
*/
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<Object|null>} 用户信息
*/
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<Object>} 更新后的用户信息
*/
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<boolean>} 删除结果
*/
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<Object>} 用户列表和分页信息
*/
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<Object>} 更新后的用户信息
*/
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<Object>} 更新后的用户信息
*/
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<Array>} 用户列表
*/
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<Object>} 统计信息
*/
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<Array>} 创建结果
*/
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<Array>} 搜索结果
*/
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 }

84
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')

11
src/utils/ForRegister.js

@ -21,10 +21,10 @@ if (import.meta.env.PROD) {
* @param {string} prefix - 路由前缀
* @param {Set<string>} [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("[路由注册] ⚠️ 未发现任何可注册的控制器")

10
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
}
}

4
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
}
}

26
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 {
/**

48
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("该接口不能登录查看")
}
if (auth === "try") {
return next()
return await 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, "")
}

2
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

19
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") 明月照佳人,用真心对待世界。<br>岁月催人老,用真情对待自己。
.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订阅

20
src/views/page/about/index.pug

@ -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") 联系方式
| 与我们取得联系。

70
src/views/page/articles/article.pug

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

29
src/views/page/articles/category.pug

@ -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 该分类下暂无文章

134
src/views/page/articles/index.pug

@ -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"
) 下一页

34
src/views/page/articles/search.pug

@ -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 未找到相关文章

32
src/views/page/articles/tag.pug

@ -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 该标签下暂无文章

10
src/views/page/index copy/index.pug

@ -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}

11
src/views/page/index/index copy 2.pug

@ -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")

69
src/views/page/index/index copy 3.pug

@ -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]');

31
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;
```

20
src/views/page/index/index.pug

@ -1 +1,19 @@
div sada
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;
```

146
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;
}
}

19
src/views/page/login/index.pug

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

7
src/views/page/notice/index.pug

@ -1,7 +0,0 @@
extends /layouts/empty.pug
block pageHead
block pageContent
div 这里是通知界面

752
src/views/page/profile/index.pug

@ -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")

119
src/views/page/register/index.pug

@ -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") 已有账号?去登录
Loading…
Cancel
Save