Browse Source

更新项目依赖,优化数据库查询缓存功能,添加验证码机制,调整页面布局和样式

- 修改 `package.json`,新增 `cross-env` 和其他依赖
- 在 `README.md` 中添加数据库查询缓存的使用说明
- 更新 `PageController.js`,优化登录和登出逻辑
- 在 `index.js` 中实现查询缓存功能,支持 TTL 和自定义 key
- 修改多个视图文件,调整导航栏和页脚样式,增强用户体验
- 删除不再使用的 CSS 文件和静态资源
re
谢亚昕 3 months ago
parent
commit
06b3a6341f
  1. 48
      README.md
  2. BIN
      bun.lockb
  3. BIN
      database/development.sqlite3-shm
  4. BIN
      database/development.sqlite3-wal
  5. 8
      package.json
  6. 48
      public/js/register.js
  7. 0
      public/lib/simplebar-shim.css
  8. 230
      public/lib/simplebar.css
  9. 10
      public/lib/simplebar.min.js
  10. 83
      public/lib/tailwindcss.3.4.17.js
  11. 1
      public/static/aa.txt
  12. 4
      public/styles.css
  13. 14
      src/controllers/Page/PageController.js
  14. 126
      src/db/index.js
  15. 28
      src/db/migrations/20250827090000_create_bookmarks_table.mjs
  16. 68
      src/db/models/BookmarkModel.js
  17. 4
      src/middlewares/Auth/index.js
  18. 6
      src/middlewares/Views/index.js
  19. 10
      src/middlewares/errorHandler/index.js
  20. 40
      src/middlewares/install.js
  21. 82
      src/utils/ForRegister.js
  22. 2
      src/views/htmx/footer.pug
  23. 11
      src/views/htmx/navbar.pug
  24. 95
      src/views/layouts/base.pug
  25. 17
      src/views/layouts/pure.pug
  26. 67
      src/views/layouts/root.pug
  27. 23
      src/views/layouts/utils.pug
  28. 8
      src/views/page/index/index.pug
  29. 29
      src/views/page/register/index.pug

48
README.md

@ -11,3 +11,51 @@
- [ ] 界面
- [ ] 定时任务
- [ ] htmx
### 数据库查询缓存(QueryBuilder 扩展)
为 Knex QueryBuilder 增加了内存级缓存能力,支持 TTL、自定义 key、只读/只写、按前缀失效与全局工具访问。默认 key 为查询的 `toString()`,也可通过 `cacheAs()` 指定。
可用方法:
- `cache(ttlMs?)`: 读取缓存,不存在则执行 SQL 并写入缓存;可选 TTL(毫秒)。
- `cacheAs(customKey)`: 为当前查询设置自定义缓存 key(链式,返回 builder)。
- `cacheSet(value, ttlMs?)`: 手动写入当前查询 key 的缓存,返回写入的值。
- `cacheGet()`: 仅从缓存读取当前查询 key 的值(不命中则返回 `undefined`,不会执行 SQL)。
- `cacheInvalidate()`: 使当前查询 key 的缓存失效(删除)。
- `cacheInvalidateByPrefix(prefix)`: 按前缀批量失效。
全局工具(命名导出 `DbQueryCache`,便于在查询构建器之外操作缓存):
- `get/set/has/delete/clear/clearByPrefix/stats()`
示例:
```js
import db, { DbQueryCache } from "./src/db/index.js"
// 1) 简单缓存 5 秒
const u1 = await db("users").where({ id: 1 }).first().cache(5000)
// 2) 自定义 key + 只读命中
const key = "users:count:active"
const count = await db("users").where({ active: 1 }).count({ c: "*" }).cacheAs(key).cache(10000)
const cachedOnly = db("users").where({ active: 1 }).cacheAs(key).cacheGet() // 命中则返回值,不命中为 undefined
// 3) 手动写入/覆盖缓存 15 秒
db("users").where({ active: 1 }).cacheAs(key).cacheSet([{ c: 123 }], 15000)
// 4) 单点或前缀失效
db("users").where({ active: 1 }).cacheAs(key).cacheInvalidate()
db.queryBuilder().cacheInvalidateByPrefix("users:")
// 5) 全局操作
DbQueryCache.clearByPrefix("users:")
const stats = DbQueryCache.stats() // { size, valid, expired }
```
注意:
- 该实现为进程内内存缓存,适合单实例与读多写少场景;多实例下需用外部缓存(如 Redis)替换/扩展。
- TTL 为可选,未设置则永久有效,直到被失效或进程重启。
- 自定义 key 建议使用明确的命名空间前缀(如 `users:`、`site:`),以便使用前缀批量失效。

BIN
bun.lockb

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

8
package.json

@ -4,13 +4,14 @@
"type": "module",
"scripts": {
"dev": "bun --hot src/main.js",
"start": "bun run src/main.js",
"start": "cross-env NODE_ENV=production bun run src/main.js",
"build": "vite build",
"migrate:make": "npx knex migrate:make ",
"migrate": "npx knex migrate:latest",
"seed:make": "npx knex seed:make ",
"seed": "npx knex seed:run ",
"init": "bun run scripts/init.js"
"dev:init": "bun run scripts/init.js",
"init": "cross-env NODE_ENV=production bun run scripts/init.js"
},
"devDependencies": {
"@types/bun": "latest",
@ -18,13 +19,16 @@
"vite": "^7.0.0"
},
"dependencies": {
"@koa/etag": "^5.0.1",
"bcryptjs": "^3.0.2",
"consolidate": "^1.0.4",
"cross-env": "^10.0.0",
"get-paths": "^0.0.7",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"koa": "^3.0.0",
"koa-bodyparser": "^4.4.1",
"koa-conditional-get": "^3.0.0",
"koa-session": "^7.0.2",
"lodash": "^4.17.21",
"log4js": "^6.9.1",

48
public/js/register.js

@ -0,0 +1,48 @@
// 注册页面验证码点击刷新功能
document.addEventListener('DOMContentLoaded', function() {
const captchaImg = document.querySelector('img[src="/captcha"]');
if (captchaImg) {
// 添加点击事件
captchaImg.addEventListener('click', function() {
// 添加时间戳防止缓存
const timestamp = new Date().getTime();
this.src = `/captcha?t=${timestamp}`;
// 添加点击反馈效果
this.style.transform = 'scale(0.95)';
setTimeout(() => {
this.style.transform = 'scale(1)';
}, 150);
});
// 添加鼠标样式提示
captchaImg.style.cursor = 'pointer';
captchaImg.title = '点击刷新验证码';
// 添加悬停效果
captchaImg.addEventListener('mouseenter', function() {
this.style.opacity = '0.8';
this.style.transition = 'opacity 0.2s ease';
});
captchaImg.addEventListener('mouseleave', function() {
this.style.opacity = '1';
});
}
// 表单验证
const registerForm = document.querySelector('form[action="/register"]');
if (registerForm) {
registerForm.addEventListener('submit', function(e) {
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm_password');
if (password.value !== confirmPassword.value) {
e.preventDefault();
alert('两次输入的密码不一致,请重新输入');
return false;
}
});
}
});

0
public/simplebar-shim.css → public/lib/simplebar-shim.css

230
public/lib/simplebar.css

@ -0,0 +1,230 @@
[data-simplebar] {
position: relative;
flex-direction: column;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-start;
}
.simplebar-wrapper {
overflow: hidden;
width: inherit;
height: inherit;
max-width: inherit;
max-height: inherit;
}
.simplebar-mask {
direction: inherit;
position: absolute;
overflow: hidden;
padding: 0;
margin: 0;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: auto !important;
height: auto !important;
z-index: 0;
}
.simplebar-offset {
direction: inherit !important;
box-sizing: inherit !important;
resize: none !important;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 0;
margin: 0;
-webkit-overflow-scrolling: touch;
}
.simplebar-content-wrapper {
direction: inherit;
box-sizing: border-box !important;
position: relative;
display: block;
height: 100%; /* Required for horizontal native scrollbar to not appear if parent is taller than natural height */
width: auto;
max-width: 100%; /* Not required for horizontal scroll to trigger */
max-height: 100%; /* Needed for vertical scroll to trigger */
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.simplebar-content-wrapper::-webkit-scrollbar,
.simplebar-hide-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.simplebar-content:before,
.simplebar-content:after {
content: ' ';
display: table;
}
.simplebar-placeholder {
max-height: 100%;
max-width: 100%;
width: 100%;
pointer-events: none;
}
.simplebar-height-auto-observer-wrapper {
box-sizing: inherit !important;
height: 100%;
width: 100%;
max-width: 1px;
position: relative;
float: left;
max-height: 1px;
overflow: hidden;
z-index: -1;
padding: 0;
margin: 0;
pointer-events: none;
flex-grow: inherit;
flex-shrink: 0;
flex-basis: 0;
}
.simplebar-height-auto-observer {
box-sizing: inherit;
display: block;
opacity: 0;
position: absolute;
top: 0;
left: 0;
height: 1000%;
width: 1000%;
min-height: 1px;
min-width: 1px;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.simplebar-track {
z-index: 1;
position: absolute;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
[data-simplebar].simplebar-dragging {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
[data-simplebar].simplebar-dragging .simplebar-content {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
[data-simplebar].simplebar-dragging .simplebar-track {
pointer-events: all;
}
.simplebar-scrollbar {
position: absolute;
left: 0;
right: 0;
min-height: 10px;
}
.simplebar-scrollbar:before {
position: absolute;
content: '';
background: black;
border-radius: 7px;
left: 2px;
right: 2px;
opacity: 0;
transition: opacity 0.2s 0.5s linear;
}
.simplebar-scrollbar.simplebar-visible:before {
opacity: 0.5;
transition-delay: 0s;
transition-duration: 0s;
}
.simplebar-track.simplebar-vertical {
top: 0;
width: 11px;
}
.simplebar-scrollbar:before {
top: 2px;
bottom: 2px;
left: 2px;
right: 2px;
}
.simplebar-track.simplebar-horizontal {
left: 0;
height: 11px;
}
.simplebar-track.simplebar-horizontal .simplebar-scrollbar {
right: auto;
left: 0;
top: 0;
bottom: 0;
min-height: 0;
min-width: 10px;
width: auto;
}
/* Rtl support */
[data-simplebar-direction='rtl'] .simplebar-track.simplebar-vertical {
right: auto;
left: 0;
}
.simplebar-dummy-scrollbar-size {
direction: rtl;
position: fixed;
opacity: 0;
visibility: hidden;
height: 500px;
width: 500px;
overflow-y: hidden;
overflow-x: scroll;
-ms-overflow-style: scrollbar !important;
}
.simplebar-dummy-scrollbar-size > div {
width: 200%;
height: 200%;
margin: 10px 0;
}
.simplebar-hide-scrollbar {
position: fixed;
left: 0;
visibility: hidden;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}

10
public/lib/simplebar.min.js

File diff suppressed because one or more lines are too long

83
public/lib/tailwindcss.3.4.17.js

File diff suppressed because one or more lines are too long

1
public/static/aa.txt

@ -1 +0,0 @@
asd

4
public/styles.css

@ -57,8 +57,8 @@ body::after {
flex: 1;
position: relative;
max-width: 1400px;
margin: 0 auto;
/* max-width: 1400px; */
/* margin: 0 auto; */
}
.content {

14
src/controllers/Page/PageController.js

@ -18,12 +18,9 @@ class PageController {
async loginGet(ctx) {
if (ctx.state.user) {
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent("用户已登录") }), {
maxAge: 1,
httpOnly: false,
path: "/",
})
return ctx.redirect("/?msg=用户已登录")
ctx.status = 200
ctx.redirect("/?msg=用户已登录")
return
}
return await ctx.render("page/login/index", { site_title: "登录" })
}
@ -121,9 +118,10 @@ class PageController {
return ctx.redirect("/login")
}
logout(ctx) {
async logout(ctx) {
ctx.status = 200
delete ctx.session.user
return ctx.redirect("/?msg=用户已退出")
ctx.set("hx-redirect", "/")
}
pageGet(name, data) {

126
src/db/index.js

@ -1,21 +1,119 @@
import buildKnex from "knex"
import knexConfig from "../../knexfile.mjs"
// const cache = {}
// 简单内存缓存(支持 TTL 与按前缀清理)
const queryCache = new Map()
// buildKnex.QueryBuilder.extend("cache", async function () {
// try {
// const cacheKey = this.toString()
// if (cache[cacheKey]) {
// return cache[cacheKey]
// }
// const data = await this
// cache[cacheKey] = data
// return data
// } catch (e) {
// throw new Error(e)
// }
// })
const getNow = () => Date.now()
const computeExpiresAt = (ttlMs) => {
if (!ttlMs || ttlMs <= 0) return null
return getNow() + ttlMs
}
const isExpired = (entry) => {
if (!entry) return true
if (entry.expiresAt == null) return false
return entry.expiresAt <= getNow()
}
const getCacheKeyForBuilder = (builder) => {
if (builder._customCacheKey) return String(builder._customCacheKey)
return builder.toString()
}
// 全局工具,便于在 QL 外部操作缓存
export const DbQueryCache = {
get(key) {
const entry = queryCache.get(String(key))
if (!entry) return undefined
if (isExpired(entry)) {
queryCache.delete(String(key))
return undefined
}
return entry.value
},
set(key, value, ttlMs) {
const expiresAt = computeExpiresAt(ttlMs)
queryCache.set(String(key), { value, expiresAt })
return value
},
has(key) {
const entry = queryCache.get(String(key))
return !!entry && !isExpired(entry)
},
delete(key) {
return queryCache.delete(String(key))
},
clear() {
queryCache.clear()
},
clearByPrefix(prefix) {
const p = String(prefix)
for (const k of queryCache.keys()) {
if (k.startsWith(p)) queryCache.delete(k)
}
},
stats() {
let valid = 0
let expired = 0
for (const [k, entry] of queryCache.entries()) {
if (isExpired(entry)) expired++
else valid++
}
return { size: queryCache.size, valid, expired }
}
}
// QueryBuilder 扩展
// 1) cache(ttlMs?): 读取缓存,不存在则执行并写入
buildKnex.QueryBuilder.extend("cache", async function (ttlMs) {
const key = getCacheKeyForBuilder(this)
const entry = queryCache.get(key)
if (entry && !isExpired(entry)) {
return entry.value
}
const data = await this
queryCache.set(key, { value: data, expiresAt: computeExpiresAt(ttlMs) })
return data
})
// 2) cacheAs(customKey): 设置自定义 key
buildKnex.QueryBuilder.extend("cacheAs", function (customKey) {
this._customCacheKey = String(customKey)
return this
})
// 3) cacheSet(value, ttlMs?): 手动设置当前查询 key 的缓存
buildKnex.QueryBuilder.extend("cacheSet", function (value, ttlMs) {
const key = getCacheKeyForBuilder(this)
queryCache.set(key, { value, expiresAt: computeExpiresAt(ttlMs) })
return value
})
// 4) cacheGet(): 仅从缓存读取当前查询 key 的值
buildKnex.QueryBuilder.extend("cacheGet", function () {
const key = getCacheKeyForBuilder(this)
const entry = queryCache.get(key)
if (!entry || isExpired(entry)) return undefined
return entry.value
})
// 5) cacheInvalidate(): 使当前查询 key 的缓存失效
buildKnex.QueryBuilder.extend("cacheInvalidate", function () {
const key = getCacheKeyForBuilder(this)
queryCache.delete(key)
return this
})
// 6) cacheInvalidateByPrefix(prefix): 按前缀清理
buildKnex.QueryBuilder.extend("cacheInvalidateByPrefix", function (prefix) {
const p = String(prefix)
for (const k of queryCache.keys()) {
if (k.startsWith(p)) queryCache.delete(k)
}
return this
})
const environment = process.env.NODE_ENV || "development"
const db = buildKnex(knexConfig[environment])

28
src/db/migrations/20250827090000_create_bookmarks_table.mjs

@ -0,0 +1,28 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async knex => {
return knex.schema.createTable("bookmarks", function (table) {
table.increments("id").primary()
table.integer("user_id").unsigned()
.references("id").inTable("users").onDelete("CASCADE")
table.string("title", 200).notNullable()
table.string("url", 500)
table.text("description")
table.timestamp("created_at").defaultTo(knex.fn.now())
table.timestamp("updated_at").defaultTo(knex.fn.now())
table.index(["user_id"]) // 常用查询索引
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async knex => {
return knex.schema.dropTable("bookmarks")
}

68
src/db/models/BookmarkModel.js

@ -0,0 +1,68 @@
import db from "../index.js"
class BookmarkModel {
static async findAllByUser(userId) {
return db("bookmarks").where("user_id", userId).orderBy("id", "desc")
}
static async findById(id) {
return db("bookmarks").where("id", id).first()
}
static async create(data) {
const userId = data.user_id
const url = typeof data.url === "string" ? data.url.trim() : data.url
if (userId != null && url) {
const exists = await db("bookmarks").where({ user_id: userId, url }).first()
if (exists) {
throw new Error("该用户下已存在相同 URL 的书签")
}
}
return db("bookmarks").insert({
...data,
url,
updated_at: db.fn.now(),
}).returning("*")
}
static async update(id, data) {
// 若更新后 user_id 与 url 同时存在,则做排他性查重(排除自身)
const current = await db("bookmarks").where("id", id).first()
if (!current) return []
const nextUserId = data.user_id != null ? data.user_id : current.user_id
const nextUrlRaw = data.url != null ? data.url : current.url
const nextUrl = typeof nextUrlRaw === "string" ? nextUrlRaw.trim() : nextUrlRaw
if (nextUserId != null && nextUrl) {
const exists = await db("bookmarks")
.where({ user_id: nextUserId, url: nextUrl })
.andWhereNot({ id })
.first()
if (exists) {
throw new Error("该用户下已存在相同 URL 的书签")
}
}
return db("bookmarks").where("id", id).update({
...data,
url: data.url != null ? nextUrl : data.url,
updated_at: db.fn.now(),
}).returning("*")
}
static async delete(id) {
return db("bookmarks").where("id", id).del()
}
static async findByUserAndUrl(userId, url) {
return db("bookmarks").where({ user_id: userId, url }).first()
}
}
export default BookmarkModel
export { BookmarkModel }

4
src/middlewares/Auth/index.js

@ -1,3 +1,3 @@
// 统一导出所有中间件
import auth from "./auth.js"
export { auth }
import Auth from "./auth.js"
export { Auth }

6
src/middlewares/Views/index.js

@ -13,8 +13,8 @@ export default viewsMiddleware
function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) {
const siteConfigService = new SiteConfigService()
return function views(ctx, next) {
if (ctx.render) return next()
return async function views(ctx, next) {
if (ctx.render) return await next()
// 将 render 注入到 context 和 response 对象中
ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) {
@ -66,7 +66,7 @@ function viewsMiddleware(path, { engineSource = consolidate, extension = "html",
}
// 中间件执行结束
return next()
return await next()
}
}

10
src/middlewares/errorHandler/index.js

@ -1,5 +1,4 @@
import { logger } from "@/logger"
import CommonError from "utils/error/CommonError"
// src/plugins/errorHandler.js
// 错误处理中间件插件
@ -38,15 +37,6 @@ export default function errorHandler() {
if (isDev && err.stack) {
console.error(err.stack)
}
if (err instanceof CommonError) {
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent(err.message) }), {
maxAge: 1,
httpOnly: false,
path: "/",
})
ctx.redirect(ctx.path+"?msg="+err.message)
return
}
await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined)
}
}

40
src/middlewares/install.js

@ -4,23 +4,27 @@ import { resolve } from "path"
import { fileURLToPath } from "url"
import path from "path"
import ErrorHandler from "./ErrorHandler"
import { auth } from "./Auth"
import { Auth } from "./Auth"
import bodyParser from "koa-bodyparser"
import Views from "./Views"
import Session from "./Session"
import Toast from "./Toast"
import etag from "@koa/etag"
import conditional from "koa-conditional-get"
import { autoRegisterControllers } from "@/utils/ForRegister.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicPath = resolve(__dirname, "../../public")
export default app => {
app.use(Toast())
// 错误处理
app.use(ErrorHandler())
// 响应时间
app.use(ResponseTime)
// session设置
app.use(Session(app));
// 权限设置
app.use(
auth({
Auth({
whiteList: [
// 所有请求放行
{ pattern: "/", auth: false },
@ -28,13 +32,13 @@ export default app => {
],
blackList: [
// 禁用api请求
"/api",
"/api/",
"/api/**/*",
// "/api",
// "/api/",
// "/api/**/*",
],
})
)
app.use(bodyParser())
// 视图设置
app.use(
Views(resolve(__dirname, "../views"), {
extension: "pug",
@ -43,13 +47,23 @@ export default app => {
}
})
)
autoRegisterControllers(app)
// 请求体解析
app.use(bodyParser())
// 自动注册控制器
autoRegisterControllers(app, path.resolve(__dirname, "../controllers"))
// 注册完成之后静态资源设置
app.use(async (ctx, next) => {
try {
await Send(ctx, ctx.path, { root: publicPath })
} catch (err) {
if (err.status !== 404) throw err
if (ctx.body) return await next()
if (ctx.status === 200) return await next()
if (ctx.method.toLowerCase() === "get") {
try {
await Send(ctx, ctx.path, { root: publicPath, maxAge: 0, immutable: false })
} catch (err) {
if (err.status !== 404) throw err
}
}
await next()
})
app.use(conditional())
app.use(etag())
}

82
src/utils/ForRegister.js

@ -20,25 +20,25 @@ if (import.meta.env.PROD) {
* @param {string} prefix - 路由前缀
* @param {Set<string>} [manualControllers] - 可选手动传入已注册 controller 文件名集合优先于自动扫描
*/
export function autoRegisterControllers(app, controllersDir = path.resolve(__dirname, "../controllers")) {
export function autoRegisterControllers(app, controllersDir) {
let allRouter = []
async function scan(dir, routePrefix = "") {
function scan(dir, routePrefix = "") {
try {
for (const file of fs.readdirSync(dir)) {
const fullPath = path.join(dir, file)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
await scan(fullPath, routePrefix + "/" + file)
scan(fullPath, routePrefix + "/" + file)
} else if (file.endsWith("Controller.js")) {
try {
// 使用动态import替代require,确保ES模块兼容性
const controllerModule = await import(fullPath)
// 使用同步的import方式,确保ES模块兼容性
const controllerModule = require(fullPath)
const controller = controllerModule.default || controllerModule
if (!controller) {
logger.warn(`Controller ${file} 没有默认导出`)
logger.warn(`[控制器注册] ${file} - 缺少默认导出,跳过注册`)
continue
}
@ -49,53 +49,67 @@ export function autoRegisterControllers(app, controllersDir = path.resolve(__dir
const router = routes()
if (router && typeof router.middleware === "function") {
allRouter.push(router)
logger.info(`成功注册控制器: ${file}`)
logger.info(`[控制器注册] ✅ ${file} - 路由创建成功`)
} else {
logger.warn(`Controller ${file} 的 createRoutes 返回的不是有效的路由器`)
logger.warn(`[控制器注册] ⚠️ ${file} - createRoutes() 返回的不是有效的路由器对象`)
}
} catch (error) {
logger.error(`执行 Controller ${file} 的 createRoutes 时出错:`, error.message)
logger.error(`[控制器注册] ❌ ${file} - createRoutes() 执行失败: ${error.message}`)
}
} else {
logger.warn(`Controller ${file} 没有 createRoutes 方法`)
logger.warn(`[控制器注册] ⚠️ ${file} - 未找到 createRoutes 方法或导出对象`)
}
} catch (importError) {
logger.error(`导入 Controller ${file} 失败:`, importError.message)
logger.error(`[控制器注册] ❌ ${file} - 模块导入失败: ${importError.message}`)
}
}
}
} catch (error) {
logger.error(`扫描目录 ${dir} 时出错:`, error.message)
logger.error(`[控制器注册] ❌ 扫描目录失败 ${dir}: ${error.message}`)
}
}
// 使用立即执行的异步函数来注册路由
(async () => {
try {
await scan(controllersDir)
try {
scan(controllersDir)
if (allRouter.length === 0) {
logger.warn("没有找到任何可注册的控制器")
return
}
if (allRouter.length === 0) {
logger.warn("[路由注册] ⚠️ 未发现任何可注册的控制器")
return
}
logger.info(`找到 ${allRouter.length} 个控制器,开始注册路由`)
logger.info(`[路由注册] 📋 发现 ${allRouter.length} 个控制器,开始注册到应用`)
// 按顺序注册路由,确保中间件执行顺序
for (let i = 0; i < allRouter.length; i++) {
const router = allRouter[i]
try {
app.use(router.middleware())
logger.debug(`路由 ${i + 1}/${allRouter.length} 注册成功`)
} catch (error) {
logger.error(`注册路由 ${i + 1}/${allRouter.length} 失败:`, error.message)
// 按顺序注册路由,确保中间件执行顺序
for (let i = 0; i < allRouter.length; i++) {
const router = allRouter[i]
try {
app.use(router.middleware())
logger.debug(`[路由注册] 🔗 路由 ${i + 1}/${allRouter.length} 注册成功`)
// 枚举并紧凑输出该路由器下的所有路由方法与路径(单行聚合)
const methodEntries = Object.entries(router.routes || {})
const items = []
for (const [method, list] of methodEntries) {
if (!Array.isArray(list) || list.length === 0) continue
for (const r of list) {
if (!r || !r.path) continue
items.push(`${method.toUpperCase()} ${r.path}`)
}
}
if (items.length > 0) {
const prefix = router.options && router.options.prefix ? router.options.prefix : ""
logger.info(`[路由注册] ✅ ${prefix || "/"}${items.length} 条 -> ${items.join("; ")}`)
} else {
logger.warn(`[路由注册] ⚠️ 该控制器未包含任何可用路由`)
}
} catch (error) {
logger.error(`[路由注册] ❌ 路由 ${i + 1}/${allRouter.length} 注册失败: ${error.message}`)
}
}
logger.info("所有路由注册完成")
logger.info(`[路由注册] ✅ 完成!成功注册 ${allRouter.length} 个控制器路由`)
} catch (error) {
logger.error("自动注册控制器过程中发生错误:", error.message)
}
})()
} catch (error) {
logger.error(`[路由注册] ❌ 自动注册过程中发生严重错误: ${error.message}`)
}
}

2
src/views/htmx/footer.pug

@ -1,6 +1,6 @@
.footer-panel
.footer-content
p © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。
p.back-to-top © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。
ul.footer-links
li

11
src/views/htmx/navbar.pug

@ -76,6 +76,11 @@ script.
.left.menu
a.menu-item(href="/about") 明月照佳人
a.menu-item(href="/about") 岁月催人老
.right.menu
a.menu-item(href="/login") 登录
a.menu-item(href="/register") 注册
if !isLogin
.right.menu
a.menu-item(href="/login") 登录
a.menu-item(href="/register") 注册
else
.right.menu
a.menu-item(hx-post="/logout") 退出
a.menu-item(href="/profile") 欢迎您 , #{$user.username}

95
src/views/layouts/base.pug

@ -56,98 +56,3 @@ html(lang="zh-CN")
block content
block scripts
+js('lib/bg-change.js')
script.
const el = document.querySelector('.simplebar-content-wrapper')
const scrollTop = sessionStorage.getItem('scrollTop-'+location.pathname)
el.scrollTop = scrollTop
el.addEventListener("scroll", function(e) {
sessionStorage.setItem('scrollTop-'+location.pathname, e.target.scrollTop)
})
function getCookie(cname) {
var name = cname + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(';');
for(var i = 0; i <ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
let have = false
const queryMsg = (new URLSearchParams(location.search)).get('msg')
let msg = getCookie('toast')
if(!msg) {
msg = JSON.stringify({type:'success',message: queryMsg})
have = !!queryMsg
} else {
have = true
}
if (have) {
function showToast(message, type = 'info') {
const containerId = 'toast-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.style.position = 'fixed';
container.style.top = '24px';
container.style.right = '24px';
container.style.zIndex = '9999';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '12px';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'toast-message';
toast.textContent = message;
// Set style based on type
let bg = '#409eff';
if (type === 'error') bg = '#f56c6c';
else if (type === 'warning') bg = '#e6a23c';
else if (type === 'success') bg = '#67c23a';
toast.style.background = bg;
toast.style.color = '#fff';
toast.style.padding = '14px 28px';
toast.style.borderRadius = '6px';
toast.style.boxShadow = '0 2px 8px rgba(0,0,0,0.12)';
toast.style.fontSize = '1rem';
toast.style.cursor = 'pointer';
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '1';
let timer;
const removeToast = () => {
toast.style.opacity = '0';
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 300);
};
const resetTimer = () => {
clearTimeout(timer);
timer = setTimeout(removeToast, 5000);
};
toast.addEventListener('mouseenter', resetTimer);
toast.addEventListener('mouseleave', resetTimer);
container.appendChild(toast);
resetTimer();
}
try {
const toastObj = JSON.parse(msg);
showToast(toastObj.message, toastObj.type);
} catch (e) {
showToast(msg);
}
}

17
src/views/layouts/pure.pug

@ -1,18 +1,13 @@
extends /layouts/base.pug
extends /layouts/root.pug
block head
block $$head
+css('styles.css')
block pageHead
block content
.page-layout
.page
.content
block pageContent
block $$content
block pageContent
footer
+include()
- var edit = false
include /htmx/footer.pug
include /htmx/footer.pug
block scripts
block $$scripts
block pageScripts

67
src/views/layouts/root.pug

@ -0,0 +1,67 @@
include utils.pug
doctype html
html(lang="zh-CN")
head
block $$head
title #{site_title || $site && $site.site_title || ''}
meta(name="description" content=site_description || $site && $site.site_description || '')
meta(name="keywords" content=keywords || $site && $site.keywords || '')
if $site && $site.site_favicon
link(rel="shortcut icon", href=$site.site_favicon)
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
+css('reset.css')
+css('lib/simplebar.css', true)
+css('lib/simplebar-shim.css')
+js('lib/htmx.min.js')
+js('lib/tailwindcss.3.4.17.js')
+js('lib/simplebar.min.js', true)
body
noscript
style.
.simplebar-content-wrapper {
scrollbar-width: auto;
-ms-overflow-style: auto;
}
.simplebar-content-wrapper::-webkit-scrollbar,
.simplebar-hide-scrollbar::-webkit-scrollbar {
display: initial;
width: initial;
height: initial;
}
div(data-simplebar style="height: 100%")
div(style="height: 100%; display: flex; flex-direction: column" id="aaaa")
block $$content
block $$scripts
+js('lib/bg-change.js')
script.
//- 处理滚动条位置
const el = document.querySelector('.simplebar-content-wrapper')
const scrollTop = sessionStorage.getItem('scrollTop-'+location.pathname)
el.scrollTop = scrollTop
el.addEventListener("scroll", function(e) {
sessionStorage.setItem('scrollTop-'+location.pathname, e.target.scrollTop)
})
//- 处理点击慢慢回到顶部
const backToTopBtn = document.querySelector('.back-to-top');
if (backToTopBtn) {
backToTopBtn.addEventListener('click', function(e) {
e.preventDefault();
const el = document.querySelector('.simplebar-content-wrapper');
if (!el) return;
const duration = 400;
const start = el.scrollTop;
const startTime = performance.now();
function animateScroll(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
el.scrollTop = start * (1 - progress);
if (progress < 1) {
requestAnimationFrame(animateScroll);
}
}
requestAnimationFrame(animateScroll);
});
}

23
src/views/layouts/utils.pug

@ -0,0 +1,23 @@
mixin include()
if block
block
//- include的使用方法
//- +include()
//- - var edit = false
//- include /htmx/footer.pug
mixin css(url, extranl = false)
if extranl || url.startsWith('http') || url.startsWith('//')
link(rel="stylesheet" type="text/css" href=url)
else
link(rel="stylesheet", href=($config && $config.base || "") + url)
mixin js(url, extranl = false)
if extranl || url.startsWith('http') || url.startsWith('//')
script(type="text/javascript" src=url)
else
script(src=($config && $config.base || "") + url)
mixin link(href, name)
//- attributes == {class: "btn"}
a(href=href)&attributes(attributes)= name

8
src/views/page/index/index.pug

@ -4,6 +4,8 @@ block pageHead
+css("css/page/index.css")
block pageContent
div(class="mt-[50px]")
+include()
include /htmx/navbar.pug
div.
sadasd
//- div(class="mt-[50px]")
//- +include()
//- include /htmx/navbar.pug

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

@ -72,6 +72,29 @@ block pageHead
}
.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
@ -87,9 +110,11 @@ block pageContent
.form-group
label(for="confirm_password") 确认密码
input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码")
img(src="/captcha", alt="")
.form-group
label(for="code") 验证码
input(type="text" id="code" name="code" required placeholder="请输入验证码")
.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