Compare commits

...

23 Commits
main ... route

Author SHA1 Message Date
谢亚昕 a302c2e836 feat: 重构时间线组件,优化页面渲染逻辑,添加未授权页面跳转功能 1 month ago
谢亚昕 aeb2b4ea67 feat: 添加查询缓存功能,优化数据库查询性能 1 month ago
npmrun 914b05192f feat: 更新个人经历时间线,添加教育和出生信息 1 month ago
谢亚昕 07a5b2ff22 feat: 更新头像样式,优化时间线布局,调整背景图像显示 1 month ago
npmrun 272664295e feat: 更新样式和布局,优化首页和关于我们页面的展示 1 month ago
谢亚昕 2c3d6c86b7 feat: 添加 Vite 配置文件,设置构建和插件选项 1 month ago
谢亚昕 dcfa188b85 feat: add background image and enhance styles across the application 1 month ago
谢亚昕 b391dcc998 feat: add site configuration management with database migration and seeding 2 months ago
谢亚昕 d06982da2b feat: Implement authentication and job management controllers 2 months ago
谢亚昕 ed60efbaf8 feat: 重构控制器,统一方法调用方式,优化路由注册逻辑 2 months ago
谢亚昕 ea8a70c43d feat: 重构用户控制器,添加自动注册路由功能,更新中间件路径 2 months ago
npmrun d079341238 feat: 更新路由中间件,添加全局认证配置,重构权限验证逻辑,尝试路由权限配置 2 months ago
谢亚昕 e7425ec594 feat: 添加站点日志记录,优化响应时间中间件,更新页面内容 2 months ago
谢亚昕 fddb11d84f feat: 更新页面渲染和用户登录功能,添加样式支持 2 months ago
谢亚昕 07dc21c1f7 feat: 在验证令牌时记录用户操作信息 2 months ago
谢亚昕 9611e33b82 feat: 更新认证中间件以添加日志记录,重构视图中间件并增强登录表单 2 months ago
npmrun f7dc33873d feat: 添加视图渲染支持,更新中间件,优化用户认证和错误处理 2 months ago
谢亚昕 8aaf9b5cd4 feat: 更新文档和路由中间件,支持中间件链 2 months ago
谢亚昕 1d142c3900 feat: add user authentication and registration features 2 months ago
谢亚昕 c073c46410 feat: 添加作业控制器和服务,重构调度器,优化日志记录和响应时间中间件 2 months ago
npmrun 838dbbd406 feat: 添加用户控制器、定时任务调度和错误处理插件,重构路由系统 2 months ago
npmrun 7d395f02bf feat: 更新路由系统并添加错误响应格式化功能 2 months ago
谢亚昕 d2e8df87f3 feat: 重构项目结构并添加静态文件服务和路由功能 2 months ago
  1. 2
      .cursorindexingignore
  2. 65
      .specstory/.what-is-this.md
  3. 1441
      .specstory/history/2025-06-17_14-17-testing-the-chat-functionality.md
  4. 0
      .trae/.ignore
  5. 29
      Dockerfile
  6. 3
      README.md
  7. BIN
      bun.lockb
  8. 0
      database/.gitkeep
  9. BIN
      database/development.sqlite3
  10. BIN
      database/development.sqlite3-shm
  11. BIN
      database/development.sqlite3-wal
  12. 28
      entrypoint.sh
  13. 29
      jsconfig.json
  14. 28
      package.json
  15. 53
      public/css/page/index.css
  16. 58
      public/js/login.js
  17. 1
      public/lib/htmx.min.js
  18. 52
      public/reset.css
  19. 21
      public/simplebar-shim.css
  20. 1
      public/static/aa.txt
  21. BIN
      public/static/bg.jpg
  22. 159
      public/styles.css
  23. 23
      scripts/init.js
  24. 3
      src/config/index.js
  25. 45
      src/controllers/Api/AuthController.js
  26. 46
      src/controllers/Api/JobController.js
  27. 20
      src/controllers/Api/StatusController.js
  28. 24
      src/controllers/Page/HtmxController.js
  29. 59
      src/controllers/Page/PageController.js
  30. 23
      src/controllers/userController.mjs
  31. 26
      src/db/index.js
  32. 5
      src/db/migrations/20250616065041_create_users_table.mjs
  33. 21
      src/db/migrations/20250621013128_site_config.mjs
  34. 42
      src/db/models/SiteConfigModel.js
  35. 12
      src/db/models/UserModel.js
  36. 22
      src/db/seeds/20250616071157_users_seed.mjs
  37. 15
      src/db/seeds/20250621013324_site_config_seed.mjs
  38. 11
      src/jobs/exampleJob.js
  39. 48
      src/jobs/index.js
  40. 52
      src/logger.js
  41. 34
      src/main.js
  42. 73
      src/middlewares/Auth/auth.js
  43. 3
      src/middlewares/Auth/index.js
  44. 3
      src/middlewares/Auth/jwt.js
  45. 63
      src/middlewares/ResponseTime/index.js
  46. 185
      src/middlewares/Send/index.js
  47. 74
      src/middlewares/Send/resolve-path.js
  48. 14
      src/middlewares/Session/index.js
  49. 74
      src/middlewares/Views/index.js
  50. 54
      src/middlewares/errorHandler/index.js
  51. 52
      src/middlewares/install.js
  52. 13
      src/plugins/ResponseTime/index.js
  53. 5
      src/plugins/install.js
  54. 18
      src/services/JobService.js
  55. 25
      src/services/SiteConfigService.js
  56. 74
      src/services/userService.js
  57. 37
      src/utils/BaseSingleton.js
  58. 78
      src/utils/ForRegister.js
  59. 11
      src/utils/bcrypt.js
  60. 4
      src/utils/helper.js
  61. 139
      src/utils/router.js
  62. 49
      src/utils/router/RouteAuth.js
  63. 60
      src/utils/scheduler.js
  64. 50
      src/views/htmx/footer.pug
  65. 4
      src/views/htmx/fuck.pug
  66. 13
      src/views/htmx/login.pug
  67. 2
      src/views/htmx/navbar.pug
  68. 178
      src/views/htmx/timeline.pug
  69. 55
      src/views/layouts/base.pug
  70. 31
      src/views/layouts/page.pug
  71. 18
      src/views/layouts/pure.pug
  72. 100
      src/views/page/about/index.pug
  73. 113
      src/views/page/articles/index.pug
  74. 54
      src/views/page/auth/no-auth.pug
  75. 10
      src/views/page/index copy/index.pug
  76. 15
      src/views/page/index/index.pug
  77. 9
      src/views/page/index/person.pug
  78. 116
      src/views/page/login/index.pug
  79. 94
      src/views/page/register/index.pug
  80. 83
      vite.config.ts

2
.cursorindexingignore

@ -0,0 +1,2 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

65
.specstory/.what-is-this.md

@ -0,0 +1,65 @@
# SpecStory Artifacts Directory
This directory is automatically created and maintained by the SpecStory extension to preserve your Cursor composer and chat history.
## What's Here?
- `.specstory/history`: Contains markdown files of your AI coding sessions
- Each file represents a separate chat or composer session
- Files are automatically updated as you work
- `.specstory/cursor_rules_backups`: Contains backups of the `.cursor/rules/derived-cursor-rules.mdc` file
- Backups are automatically created each time the `.cursor/rules/derived-cursor-rules.mdc` file is updated
- You can enable/disable the Cursor Rules feature in the SpecStory settings, it is disabled by default
## Valuable Uses
- Capture: Keep your context window up-to-date when starting new Chat/Composer sessions via @ references
- Search: For previous prompts and code snippets
- Learn: Meta-analyze your patterns and learn from your past experiences
- Derive: Keep Cursor on course with your past decisions by automatically deriving Cursor rules from your AI interactions
## Version Control
We recommend keeping this directory under version control to maintain a history of your AI interactions. However, if you prefer not to version these files, you can exclude them by adding this to your `.gitignore`:
```
.specstory
```
We recommend not keeping the `.specstory/cursor_rules_backups` directory under version control if you are already using git to version the `.cursor/rules` directory, and committing regularly. You can exclude it by adding this to your `.gitignore`:
```
.specstory/cursor_rules_backups
```
## Searching Your Codebase
When searching your codebase in Cursor, search results may include your previous AI coding interactions. To focus solely on your actual code files, you can exclude the AI interaction history from search results.
To exclude AI interaction history:
1. Open the "Find in Files" search in Cursor (Cmd/Ctrl + Shift + F)
2. Navigate to the "files to exclude" section
3. Add the following pattern:
```
.specstory/*
```
This will ensure your searches only return results from your working codebase files.
## Notes
- Auto-save only works when Cursor/sqlite flushes data to disk. This results in a small delay after the AI response is complete before SpecStory can save the history.
- Auto-save does not yet work on remote WSL workspaces.
## Settings
You can control auto-saving behavior in Cursor:
1. Open Cursor → Settings → VS Code Settings (Cmd/Ctrl + ,)
2. Search for "SpecStory"
3. Find "Auto Save" setting to enable/disable
Auto-save occurs when changes are detected in Cursor's sqlite database, or every 2 minutes as a safety net.

1441
.specstory/history/2025-06-17_14-17-testing-the-chat-functionality.md

File diff suppressed because it is too large

0
.trae/.ignore

29
Dockerfile

@ -0,0 +1,29 @@
# 使用官方 Bun 运行时的轻量级镜像
FROM oven/bun:alpine as base
WORKDIR /app
# 仅复制生产依赖相关文件
COPY package.json bun.lockb knexfile.mjs .npmrc ./
# 安装依赖(生产环境)
RUN bun install --production
# 复制应用代码和静态资源
COPY src ./src
COPY public ./public
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
# 如需数据库文件(如 SQLite),可挂载到宿主机
VOLUME /app/database
# 启动命令(如有端口需求可暴露端口)
EXPOSE 3000
# 健康检查:每30秒检查一次服务端口,3次失败则容器为unhealthy
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --spider -q http://localhost:3000/ || exit 1
ENTRYPOINT ["./entrypoint.sh"]

3
README.md

@ -9,4 +9,5 @@
- [x] 数据库
- [ ] 缓存
- [ ] 界面
- [ ] 定时任务
- [ ] 定时任务
- [ ] htmx

BIN
bun.lockb

Binary file not shown.

0
database/.gitkeep

BIN
database/development.sqlite3

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

28
entrypoint.sh

@ -0,0 +1,28 @@
#!/bin/sh
set -e
# 数据库文件路径(可根据实际环境调整)
DB_FILE=./database/db.sqlite3
ENV=${NODE_ENV:-production}
# 检查 bun 是否存在
if command -v bun >/dev/null 2>&1; then
RUNNER="bun run"
START="exec bun src/main.js"
else
RUNNER="npx"
START="exec npm run start"
fi
# 如果数据库文件不存在,先 migrate 再 seed
if [ ! -f "$DB_FILE" ]; then
echo "Database not found, running migration and seed..."
$RUNNER npx knex migrate:latest --env $ENV
$RUNNER npx knex seed:run --env $ENV
else
echo "Database exists, running migration only..."
$RUNNER npx knex migrate:latest
fi
# 启动主服务
$START

29
jsconfig.json

@ -0,0 +1,29 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
],
"db/*": [
"src/db/*"
],
"config/*": [
"src/config/*"
],
"utils/*": [
"src/utils/*"
],
"services/*": [
"src/services/*"
]
},
"module": "commonjs",
"target": "es6",
"allowSyntheticDefaultImports": true
},
"include": [
"src/**/*",
"jsconfig.json"
]
}

28
package.json

@ -5,24 +5,42 @@
"scripts": {
"dev": "bun --hot src/main.js",
"start": "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 "
"seed": "npx knex seed:run ",
"init": "bun run scripts/init.js"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"knex": "^3.1.0"
"vite": "^7.0.0"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"consolidate": "^1.0.4",
"get-paths": "^0.0.7",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"koa": "^3.0.0",
"koa-bodyparser": "^4.4.1",
"koa-session": "^7.0.2",
"lodash": "^4.17.21",
"log4js": "^6.9.1",
"minimatch": "^9.0.0",
"module-alias": "^2.2.3",
"sqlite3": "^5.1.7"
"node-cron": "^4.1.0",
"path-to-regexp": "^8.2.0",
"pug": "^3.0.3",
"sqlite3": "^5.1.7",
"vite-plugin-static-copy": "^3.1.0"
},
"_moduleAliases": {
"@": "./src",
"db": "./src/db"
"config": "./src/config",
"db": "./src/db",
"utils": "./src/utils",
"services": "./src/services"
}
}
}

53
public/css/page/index.css

@ -0,0 +1,53 @@
.home-hero {
margin: 40px 20px 40px;
/* background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px); */
text-align: center;
}
.avatar-container {
width: 120px;
height: 120px;
margin: 0 auto;
position: relative;
}
.avatar-container .author {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 20px;
font-weight: bold;
}
.avatar-container:hover .avatar {
transform: rotate(360deg);
left: 100%;
}
.avatar {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
cursor: pointer;
left: 0;
transform-origin: center center;
transition: 0.5s transform ease-in-out, 0.5s left ease-in-out;
}
.card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
border-radius: 8px;
padding: 20px;
width: 300px;
margin: 0 auto;
margin-bottom: 40px;
text-align: center;
}
@media screen and (max-width: 768px) {
.home-hero {
margin: 0;
margin-top: 20px;
}
}

58
public/js/login.js

@ -0,0 +1,58 @@
let loginToastTimer = null;
function showLoginToast(msg, isSuccess = false) {
let toast = document.getElementById("login-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "login-toast";
toast.style.position = "fixed";
toast.style.top = "20px";
toast.style.right = "20px";
toast.style.left = "auto";
toast.style.transform = "none";
toast.style.minWidth = "220px";
toast.style.maxWidth = "80vw";
toast.style.background = isSuccess ? "linear-gradient(90deg,#7ec6f7,#b2f7ef)" : "#fff";
toast.style.color = isSuccess ? "#1976d2" : "#ff4d4f";
toast.style.fontSize = "1.08rem";
toast.style.fontWeight = "600";
toast.style.padding = "1.1em 2.2em";
toast.style.borderRadius = "18px";
toast.style.boxShadow = "0 4px 24px rgba(30,136,229,0.13),0 1.5px 8px rgba(79,209,255,0.08)";
toast.style.zIndex = 9999;
toast.style.textAlign = "center";
toast.style.opacity = "0";
toast.style.transition = "opacity 0.2s";
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.style.opacity = "1";
if (loginToastTimer) clearTimeout(loginToastTimer);
loginToastTimer = setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => {
toast.remove();
}, 300);
loginToastTimer = null;
}, 2000);
}
const loginForm = document.getElementById("login-form")
loginForm.onsubmit = async function (e) {
e.preventDefault()
const form = e.target
const data = Object.fromEntries(new FormData(form))
const res = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const result = await res.json()
if (result.success) {
showLoginToast("登录成功,2秒后跳转到首页", true)
setTimeout(() => {
window.location.href = "/"
}, 2000)
} else {
showLoginToast(result.message || "登录失败")
}
}

1
public/lib/htmx.min.js

File diff suppressed because one or more lines are too long

52
public/reset.css

@ -0,0 +1,52 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
* {
box-sizing: border-box
}

21
public/simplebar-shim.css

@ -0,0 +1,21 @@
.simplebar-content {
height: 100%;
}
.simplebar-scrollbar::before {
background-color: #bdbdbd;
border-radius: 0;
left: 0;
right: 0;
bottom: 0;
top: 0;
}
.simplebar-hover .simplebar-scrollbar::before {
background-color: #909090;
}
.simplebar-wrapper:hover ~ .simplebar-track > .simplebar-scrollbar:before {
opacity: 0.5 !important;
}

1
public/static/aa.txt

@ -0,0 +1 @@
asd

BIN
public/static/bg.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

159
public/styles.css

@ -0,0 +1,159 @@
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: Arial, sans-serif;
color: #333;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
body::after {
content: "";
position: fixed;
pointer-events: none;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-image: var(--bg);
background-size: cover;
background-repeat: no-repeat;
background-color: #f9f9f9;
filter: brightness(.55);
z-index: -1;
}
.navbar {
height: 49px;
display: flex;
align-items: center;
box-shadow: 1px 1px 3px #e4e4e4;
}
.title {
font-size: 1.5em;
margin-left: 10px;
color: #333;
}
.page-layout {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.page {
width: 100%;
display: flex;
flex-direction: row;
flex: 1;
position: relative;
max-width: 1400px;
margin: 0 auto;
}
.content {
flex: 1;
width: 0;
}
.footer {}
.nav {
padding: 20px;
}
.flota-nav {
position: sticky;
left: 0;
top: 20px;
width: 80px;
padding: 40px 0;
border-radius: 80px;
/* background: linear-gradient(135deg, #4fd1ff 0%, #ff6a6a 100%); */
/* 更明亮的渐变色 */
/* box-shadow: 0 8px 32px 0 rgba(80, 80, 200, 0.18), 0 2px 16px 0 rgba(255, 106, 106, 0.12); */
/* 更明显的阴影 */
/* transition: background 0.3s, box-shadow 0.3s; */
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
}
.flota-nav .item {
display: block;
width: 100%;
height: 40px;
line-height: 40px;
color: #fff;
text-align: center;
cursor: pointer;
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
letter-spacing: 1px;
margin-bottom: 8px;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px 0 rgba(79, 209, 255, 0.10);
/* item 也加轻微阴影 */
}
.flota-nav .item:hover {
background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%);
color: #fff200;
box-shadow: 0 4px 16px 0 rgba(255, 184, 108, 0.18);
}
.flota-nav .item.active {
background: #fff200;
color: #ff6a6a;
box-shadow: 0 4px 20px 0 rgba(255, 242, 0, 0.22);
}
.card {
padding: 56px 40px 44px 40px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
color: #fff;
}
@media screen and (max-width: 768px) {
.nav {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
}
.flota-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100%;
display: flex;
z-index: 9999;
padding: 0 10px;
height: 40px;
border-radius: 0;
}
.flota-nav .item{
margin-bottom: 0;
padding: 0 10px;
}
.content {
padding: 0 10px;
padding-top: 40px;
}
}

23
scripts/init.js

@ -0,0 +1,23 @@
const { execSync } = require('child_process');
const readline = require('readline');
// 写一个执行npm run migrate && npm run seed的脚本,当执行npm run seed时会谈提示是否重置数据
function run(command) {
execSync(command, { stdio: 'inherit' });
}
run('npx knex migrate:latest');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('是否重置数据?(y/N): ', (answer) => {
if (answer.trim().toLowerCase() === 'y') {
run('npx knex seed:run');
} else {
console.log('已取消数据重置。');
}
rl.close();
});

3
src/config/index.js

@ -0,0 +1,3 @@
export default {
base: "/",
}

45
src/controllers/Api/AuthController.js

@ -0,0 +1,45 @@
import UserService from "services/UserService.js"
import { formatResponse } from "utils/helper.js"
import Router from "utils/router.js"
class AuthController {
constructor() {
this.userService = new UserService()
}
async hello(ctx) {
ctx.body = formatResponse(true, "Hello World")
}
async getUser(ctx) {
const user = await this.userService.getUserById(ctx.params.id)
ctx.body = formatResponse(true, user)
}
async register(ctx) {
const { username, email, password } = ctx.request.body
const user = await this.userService.register({ username, email, password })
ctx.body = formatResponse(true, user)
}
async login(ctx) {
const { username, email, password } = ctx.request.body
const result = await this.userService.login({ username, email, password })
ctx.body = formatResponse(true, result)
}
/**
* 路由注册
*/
static createRoutes() {
const controller = new AuthController()
const router = new Router({ prefix: "/api" })
router.get("/hello", controller.hello.bind(controller), { auth: false })
router.get("/user/:id", controller.getUser.bind(controller))
router.post("/register", controller.register.bind(controller))
router.post("/login", controller.login.bind(controller))
return router
}
}
export default AuthController

46
src/controllers/Api/JobController.js

@ -0,0 +1,46 @@
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
import JobService from "services/JobService.js"
import { formatResponse } from "utils/helper.js"
import Router from "utils/router.js"
class JobController {
constructor() {
this.jobService = new JobService()
}
async list(ctx) {
const data = this.jobService.listJobs()
ctx.body = formatResponse(true, data)
}
async start(ctx) {
const { id } = ctx.params
this.jobService.startJob(id)
ctx.body = formatResponse(true, null, null, `${id} 任务已启动`)
}
async stop(ctx) {
const { id } = ctx.params
this.jobService.stopJob(id)
ctx.body = formatResponse(true, null, null, `${id} 任务已停止`)
}
async updateCron(ctx) {
const { id } = ctx.params
const { cronTime } = ctx.request.body
this.jobService.updateJobCron(id, cronTime)
ctx.body = formatResponse(true, null, null, `${id} 任务频率已修改`)
}
static createRoutes() {
const controller = new JobController()
const router = new Router({ prefix: "/api/jobs" })
router.get("/", controller.list.bind(controller))
router.post("/start/:id", controller.start.bind(controller))
router.post("/stop/:id", controller.stop.bind(controller))
router.post("/update/:id", controller.updateCron.bind(controller))
return router
}
}
export default JobController

20
src/controllers/Api/StatusController.js

@ -0,0 +1,20 @@
import Router from "utils/router.js"
class StatusController {
async status(ctx) {
ctx.body = "OK"
}
static createRoutes() {
const controller = new StatusController()
const v1 = new Router({ prefix: "/api/v1" })
v1.use((ctx, next) => {
ctx.set("X-API-Version", "v1")
return next()
})
v1.get("/status", controller.status.bind(controller))
return v1
}
}
export default StatusController

24
src/controllers/Page/HtmxController.js

@ -0,0 +1,24 @@
import Router from "utils/router.js"
class HtmxController {
async index(ctx) {
return await ctx.render("index", { name: "bluescurry" })
}
page(name, data) {
return async ctx => {
return await ctx.render(name, data)
}
}
static createRoutes() {
const controller = new HtmxController()
const router = new Router({ auth: "try" })
router.post("/clicked", async ctx => {
return await ctx.render("htmx/fuck", { title: "HTMX Clicked" })
})
return router
}
}
export default HtmxController

59
src/controllers/Page/PageController.js

@ -0,0 +1,59 @@
import Router from "utils/router.js"
import UserService from "services/UserService.js"
import SiteConfigService from "services/SiteConfigService.js"
class PageController {
constructor() {
this.userService = new UserService()
this.siteConfigService = new SiteConfigService()
}
async indexGet(ctx) {
return await ctx.render("page/index/index", {}, { includeSite: true, includeUser: true })
}
async indexNoAuth(ctx) {
return await ctx.render("page/auth/no-auth", {})
}
async loginPost(ctx) {
const { username, email, password } = ctx.request.body
const result = await this.userService.login({ username, email, password })
ctx.session.user = result.user
ctx.body = { success: true, message: "登录成功" }
}
async registerPost(ctx) {
const { username, email, password } = ctx.request.body
await this.userService.register({ username, email, password })
return ctx.redirect("/login")
}
pageGet(name, data) {
return async ctx => {
return await ctx.render(name, {
...(data || {}),
user: ctx.state.user,
}, { includeSite: true })
}
}
static createRoutes() {
const controller = new PageController()
const router = new Router({ auth: "try" })
// 首页
router.get("/", controller.indexGet.bind(controller), { auth: false })
// 未授权报错页
router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false })
router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false })
router.get("/articles", controller.pageGet("page/articles/index"), { auth: false })
router.get("/about", controller.pageGet("page/about/index"), { auth: false })
router.get("/login", controller.pageGet("page/login/index"), { auth: false })
router.post("/login", controller.loginPost.bind(controller), { auth: false })
router.get("/register", controller.pageGet("page/register/index", { title: "注册" }), { auth: false })
router.post("/register", controller.registerPost.bind(controller), { auth: false })
return router
}
}
export default PageController

23
src/controllers/userController.mjs

@ -1,23 +0,0 @@
import db from "../db/index.js"
// 创建用户
export async function createUser(userData) {
const [id] = await db("users").insert(userData)
return id
}
// 查询所有用户
export async function getUsers() {
return db("users").select("*")
}
// 更新用户
export async function updateUser(id, updates) {
updates.updated_at = new Date()
return db("users").where("id", id).update(updates)
}
// 删除用户
export async function deleteUser(id) {
return db("users").where("id", id).del()
}

26
src/db/index.js

@ -1,10 +1,26 @@
import buildKnex from "knex"
import knexConfig from "../../knexfile.mjs"
const environment = process.env.NODE_ENV || 'development';
const db = buildKnex(knexConfig[environment]);
const cache = {}
export default db;
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 environment = process.env.NODE_ENV || "development"
const db = buildKnex(knexConfig[environment])
export default db
// async function createDatabase() {
// try {
@ -14,8 +30,8 @@ export default db;
// // 检查users表是否存在(示例)
// const [tableExists] = await db.raw(`
// SELECT name
// FROM sqlite_master
// SELECT name
// FROM sqlite_master
// WHERE type='table' AND name='users'
// `)

5
src/db/migrations/20250616065041_create_users_table.mjs

@ -5,8 +5,11 @@
export const up = async knex => {
return knex.schema.createTable("users", function (table) {
table.increments("id").primary() // 自增主键
table.string("name", 100).notNullable() // 字符串字段(最大长度100)
table.string("username", 100).notNullable() // 字符串字段(最大长度100)
table.string("email", 100).unique().notNullable() // 唯一邮箱
table.string("password", 100).notNullable() // 密码
table.string("role", 100).notNullable()
table.string("phone", 100)
table.integer("age").unsigned() // 无符号整数
table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间

21
src/db/migrations/20250621013128_site_config.mjs

@ -0,0 +1,21 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async knex => {
return knex.schema.createTable("site_config", function (table) {
table.increments("id").primary() // 自增主键
table.string("key", 100).notNullable().unique() // 配置项key,唯一
table.text("value").notNullable() // 配置项value
table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async knex => {
return knex.schema.dropTable("site_config") // 回滚时删除表
}

42
src/db/models/SiteConfigModel.js

@ -0,0 +1,42 @@
import db from "../index.js"
class SiteConfigModel {
// 获取指定key的配置
static async get(key) {
const row = await db("site_config").where({ key }).first()
return row ? row.value : null
}
// 设置指定key的配置(有则更新,无则插入)
static async set(key, value) {
const exists = await db("site_config").where({ key }).first()
if (exists) {
await db("site_config").where({ key }).update({ value, updated_at: db.fn.now() })
} else {
await db("site_config").insert({ key, value })
}
}
// 批量获取多个key的配置
static async getMany(keys) {
const rows = await db("site_config").whereIn("key", keys)
const result = {}
rows.forEach(row => {
result[row.key] = row.value
})
return result
}
// 获取所有配置
static async getAll() {
const rows = await db("site_config").select("key", "value")
const result = {}
rows.forEach(row => {
result[row.key] = row.value
})
return result
}
}
export default SiteConfigModel
export { SiteConfigModel }

12
src/db/models/UserModel.js

@ -10,7 +10,10 @@ class UserModel {
}
static async create(data) {
return db("users").insert(data).returning("*")
return db("users").insert({
...data,
updated_at: db.fn.now(),
}).returning("*")
}
static async update(id, data) {
@ -20,6 +23,13 @@ class UserModel {
static async delete(id) {
return db("users").where("id", id).del()
}
static async findByUsername(username) {
return db("users").where("username", username).first()
}
static async findByEmail(email) {
return db("users").where("email", email).first()
}
}
export default UserModel

22
src/db/seeds/20250616071157_users_seed.mjs

@ -1,19 +1,17 @@
export const seed = async knex => {
// 检查表是否存在
const tables = await knex.raw(`
SELECT name FROM sqlite_master WHERE type='table' AND name='users'
`)
// 检查表是否存在
const hasUsersTable = await knex.schema.hasTable('users');
if (tables.length === 0) {
console.error("表 users 不存在,请先执行迁移")
return
}
if (!hasUsersTable) {
console.error("表 users 不存在,请先执行迁移")
return
}
// Deletes ALL existing entries
await knex("users").del()
// Inserts seed entries
await knex("users").insert([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
])
// await knex("users").insert([
// { username: "Alice", email: "alice@example.com" },
// { username: "Bob", email: "bob@example.com" },
// ])
}

15
src/db/seeds/20250621013324_site_config_seed.mjs

@ -0,0 +1,15 @@
export const seed = async (knex) => {
// 删除所有已有配置
await knex('site_config').del();
// 插入常用站点配置项
await knex('site_config').insert([
{ key: 'site_title', value: '罗非鱼的秘密' },
{ key: 'site_author', value: '罗非鱼' },
{ key: 'site_description', value: '一屋很小,却也很大' },
{ key: 'site_logo', value: '/static/logo.png' },
{ key: 'site_bg', value: '/static/bg.jpg' },
{ key: 'keywords', value: 'blog' },
{ key: 'base', value: '/' }
]);
};

11
src/jobs/exampleJob.js

@ -0,0 +1,11 @@
import { jobLogger } from "@/logger";
export default {
id: 'example',
cronTime: '*/10 * * * * *', // 每10秒执行一次
task: () => {
jobLogger.info('Example Job 执行了');
},
options: {},
autoStart: false
};

48
src/jobs/index.js

@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import scheduler from 'utils/scheduler.js';
const jobsDir = __dirname;
const jobModules = {};
fs.readdirSync(jobsDir).forEach(file => {
if (file === 'index.js' || !file.endsWith('Job.js')) return;
const jobModule = require(path.join(jobsDir, file));
const job = jobModule.default || jobModule;
if (job && job.id && job.cronTime && typeof job.task === 'function') {
jobModules[job.id] = job;
scheduler.add(job.id, job.cronTime, job.task, job.options);
if (job.autoStart) scheduler.start(job.id);
}
});
function callHook(id, hookName) {
const job = jobModules[id];
if (job && typeof job[hookName] === 'function') {
try {
job[hookName]();
} catch (e) {
console.error(`[Job:${id}] ${hookName} 执行异常:`, e);
}
}
}
export default {
start: id => {
callHook(id, 'beforeStart');
scheduler.start(id);
},
stop: id => {
scheduler.stop(id);
callHook(id, 'afterStop');
},
updateCronTime: (id, cronTime) => scheduler.updateCronTime(id, cronTime),
list: () => scheduler.list(),
reload: id => {
const job = jobModules[id];
if (job) {
scheduler.remove(id);
scheduler.add(job.id, job.cronTime, job.task, job.options);
}
}
};

52
src/logger.js

@ -9,6 +9,10 @@ log4js.configure({
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
all: {
type: "file",
@ -17,6 +21,10 @@ log4js.configure({
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
error: {
type: "file",
@ -25,15 +33,57 @@ log4js.configure({
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
jobs: {
type: "file",
filename: "logs/jobs.log",
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
site: {
type: "file",
filename: "logs/site.log",
maxLogSize: 102400,
pattern: "-yyyy-MM-dd.log",
alwaysIncludePattern: true,
backups: 3,
layout: {
type: 'pattern',
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m',
},
},
console: {
type: "console",
layout: { type: "colored" },
layout: {
type: "pattern",
pattern: '\x1b[36m[%d{yyyy-MM-dd hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m',
},
},
},
categories: {
jobs: { appenders: ["console", "jobs"], level: "ALL" },
site: { appenders: ["site"], level: "ALL" },
console: { appenders: ["console"], level: "ALL" },
error: { appenders: ["console", "error"], level: "error" },
default: { appenders: ["console", "all"], level: "ALL" },
debug: { appenders: ["debug"], level: "debug" },
},
})
// 导出常用logger实例,便于直接引用
export const logger = log4js.getLogger();
export const debugLogger = log4js.getLogger('debug');
export const jobLogger = log4js.getLogger('jobs');
export const errorLogger = log4js.getLogger('error');
export const siteLogger = log4js.getLogger('site');
export const consoleLogger = log4js.getLogger('console');

34
src/main.js

@ -1,27 +1,24 @@
import "./logger"
import "module-alias/register"
// 日志、全局插件、定时任务等基础设施
import { consoleLogger } from "./logger.js"
import "./jobs/index.js"
// 第三方依赖
import Koa from "koa"
import os from "os"
import LoadPlugins from "./plugins/install"
import UserModel from "./db/models/UserModel"
import log4js from "log4js"
const logger = log4js.getLogger()
// 应用插件与自动路由
import LoadMiddlewares from "./middlewares/install.js"
const app = new Koa()
LoadPlugins(app)
// 注册插件
LoadMiddlewares(app)
app.use(async ctx => {
ctx.body = await UserModel.findAll()
})
const PORT = process.env.PORT || 3000
app.on("error", err => {
logger.error("server error", err)
})
const server = app.listen(3000, () => {
const server = app.listen(PORT, () => {
const port = server.address().port
// 获取本地 IP
const getLocalIP = () => {
const interfaces = os.networkInterfaces()
for (const name of Object.keys(interfaces)) {
@ -34,5 +31,10 @@ const server = app.listen(3000, () => {
return "localhost"
}
const localIP = getLocalIP()
logger.trace(`服务器运行在: http://${localIP}:${port}`)
consoleLogger.trace(`===================【服务器地址】====================`)
consoleLogger.trace(` http://localhost:${port} (本地地址) `)
consoleLogger.trace(` http://${localIP}:${port} (本地地址) `)
consoleLogger.trace(`===================【服务器地址】====================`)
})
export default app

73
src/middlewares/Auth/auth.js

@ -0,0 +1,73 @@
import { logger } from "@/logger"
import jwt from "./jwt"
import { minimatch } from "minimatch"
export const JWT_SECRET = process.env.JWT_SECRET || "jwt-demo-secret"
function matchList(list, path) {
for (const item of list) {
if (typeof item === "string" && minimatch(path, item)) {
return { matched: true, auth: false }
}
if (typeof item === "object" && minimatch(path, item.pattern)) {
return { matched: true, auth: item.auth }
}
}
return { matched: false }
}
function verifyToken(ctx) {
let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "")
if (!token) {
return { ok: false, status: -1 }
}
try {
ctx.state.user = jwt.verify(token, JWT_SECRET)
return { ok: true }
} catch {
ctx.state.user = undefined
return { ok: false }
}
}
export default function authMiddleware(options = {
whiteList: [],
blackList: []
}) {
return async (ctx, next) => {
if(ctx.session.user) {
ctx.state.user = ctx.session.user
}
// 黑名单优先生效
if (matchList(options.blackList, ctx.path).matched) {
ctx.status = 403
ctx.body = { success: false, error: "禁止访问" }
return
}
// 白名单处理
const white = matchList(options.whiteList, ctx.path)
if (white.matched) {
if (white.auth === false) {
return await next()
}
if (white.auth === "try") {
verifyToken(ctx)
return await next()
}
// true 或其他情况,必须有token
if (!verifyToken(ctx).ok) {
ctx.status = 401
ctx.body = { success: false, error: "未登录或token缺失或无效" }
return
}
return await next()
}
// 非白名单,必须有token
if (!verifyToken(ctx).ok) {
ctx.status = 401
ctx.body = { success: false, error: "未登录或token缺失或无效" }
return
}
await next()
}
}

3
src/middlewares/Auth/index.js

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

3
src/middlewares/Auth/jwt.js

@ -0,0 +1,3 @@
// 兼容性导出,便于后续扩展
import jwt from "jsonwebtoken"
export default jwt

63
src/middlewares/ResponseTime/index.js

@ -0,0 +1,63 @@
import { siteLogger, logger } from "@/logger"
// 静态资源扩展名列表
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"]
function isStaticResource(path) {
return staticExts.some(ext => path.endsWith(ext))
}
/**
* 响应时间记录中间件
* @param {Object} ctx - Koa上下文对象
* @param {Function} next - Koa中间件链函数
*/
export default async (ctx, next) => {
if (isStaticResource(ctx.path)) {
await next()
return
}
if (!ctx.path.includes("/api")) {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
if (ms > 500) {
siteLogger.info(`${ctx.path} | ⏱️ ${ms}ms`)
}
return
}
// API日志记录
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
const Threshold = 0
if (ms > Threshold) {
logger.info("====================[➡️REQ]====================")
// 用户信息(假设ctx.state.user存在)
const user = ctx.state && ctx.state.user ? ctx.state.user : null
// IP
const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress
// 请求参数
const params = {
query: ctx.query,
body: ctx.request.body,
}
// 响应状态码
const status = ctx.status
// 组装日志对象
const logObj = {
method: ctx.method,
path: ctx.path,
url: ctx.url,
user: user ? { id: user.id, username: user.username } : null,
ip,
params,
status,
ms,
}
logger.info(JSON.stringify(logObj, null, 2))
logger.info("====================[⬅️END]====================\n")
}
}

185
src/middlewares/Send/index.js

@ -0,0 +1,185 @@
/**
* koa-send@5.0.1 转换为ES Module版本
* 静态资源服务中间件
*/
import fs from 'fs';
import { promisify } from 'util';
import logger from 'log4js';
import resolvePath from './resolve-path.js';
import createError from 'http-errors';
import assert from 'assert';
import { normalize, basename, extname, resolve, parse, sep } from 'path';
import { fileURLToPath } from 'url';
import path from "path"
// 转换为ES Module格式
const log = logger.getLogger('koa-send');
const stat = promisify(fs.stat);
const access = promisify(fs.access);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 检查文件是否存在
* @param {string} path - 文件路径
* @returns {Promise<boolean>} 文件是否存在
*/
async function exists(path) {
try {
await access(path);
return true;
} catch (e) {
return false;
}
}
/**
* 发送文件给客户端
* @param {Context} ctx - Koa上下文对象
* @param {String} path - 文件路径
* @param {Object} [opts] - 配置选项
* @returns {Promise} - 异步Promise
*/
async function send(ctx, path, opts = {}) {
assert(ctx, 'koa context required');
assert(path, 'pathname required');
// 移除硬编码的public目录,要求必须通过opts.root配置
const root = opts.root;
if (!root) {
throw new Error('Static root directory must be configured via opts.root');
}
const trailingSlash = path[path.length - 1] === '/';
path = path.substr(parse(path).root.length);
const index = opts.index || 'index.html';
const maxage = opts.maxage || opts.maxAge || 0;
const immutable = opts.immutable || false;
const hidden = opts.hidden || false;
const format = opts.format !== false;
const extensions = Array.isArray(opts.extensions) ? opts.extensions : false;
const brotli = opts.brotli !== false;
const gzip = opts.gzip !== false;
const setHeaders = opts.setHeaders;
if (setHeaders && typeof setHeaders !== 'function') {
throw new TypeError('option setHeaders must be function');
}
// 解码路径
path = decode(path);
if (path === -1) return ctx.throw(400, 'failed to decode');
// 索引文件支持
if (index && trailingSlash) path += index;
path = resolvePath(root, path);
// 隐藏文件支持
if (!hidden && isHidden(root, path)) return;
let encodingExt = '';
// 尝试提供压缩文件
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) {
path = path + '.br';
ctx.set('Content-Encoding', 'br');
ctx.res.removeHeader('Content-Length');
encodingExt = '.br';
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) {
path = path + '.gz';
ctx.set('Content-Encoding', 'gzip');
ctx.res.removeHeader('Content-Length');
encodingExt = '.gz';
}
// 尝试添加文件扩展名
if (extensions && !/\./.exec(basename(path))) {
const list = [].concat(extensions);
for (let i = 0; i < list.length; i++) {
let ext = list[i];
if (typeof ext !== 'string') {
throw new TypeError('option extensions must be array of strings or false');
}
if (!/^\./.exec(ext)) ext = `.${ext}`;
if (await exists(`${path}${ext}`)) {
path = `${path}${ext}`;
break;
}
}
}
// 获取文件状态
let stats;
try {
stats = await stat(path);
// 处理目录
if (stats.isDirectory()) {
if (format && index) {
path += `/${index}`;
stats = await stat(path);
} else {
return;
}
}
} catch (err) {
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
if (notfound.includes(err.code)) {
throw createError(404, err);
}
err.status = 500;
throw err;
}
if (setHeaders) setHeaders(ctx.res, path, stats);
// 设置响应头
ctx.set('Content-Length', stats.size);
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString());
if (!ctx.response.get('Cache-Control')) {
const directives = [`max-age=${(maxage / 1000) | 0}`];
if (immutable) directives.push('immutable');
ctx.set('Cache-Control', directives.join(','));
}
if (!ctx.type) ctx.type = type(path, encodingExt);
ctx.body = fs.createReadStream(path);
return path;
}
/**
* 检查是否为隐藏文件
* @param {string} root - 根目录
* @param {string} path - 文件路径
* @returns {boolean} 是否为隐藏文件
*/
function isHidden(root, path) {
path = path.substr(root.length).split(sep);
for (let i = 0; i < path.length; i++) {
if (path[i][0] === '.') return true;
}
return false;
}
/**
* 获取文件类型
* @param {string} file - 文件路径
* @param {string} ext - 编码扩展名
* @returns {string} 文件MIME类型
*/
function type(file, ext) {
return ext !== '' ? extname(basename(file, ext)) : extname(file);
}
/**
* 解码URL路径
* @param {string} path - 需要解码的路径
* @returns {string|number} 解码后的路径或错误代码
*/
function decode(path) {
try {
return decodeURIComponent(path);
} catch (err) {
return -1;
}
}
export default send;

74
src/middlewares/Send/resolve-path.js

@ -0,0 +1,74 @@
/*!
* resolve-path
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2015-2018 Douglas Christopher Wilson
* MIT Licensed
*/
/**
* ES Module 转换版本
* 路径解析工具防止路径遍历攻击
*/
import createError from 'http-errors';
import { join, normalize, resolve, sep } from 'path';
import pathIsAbsolute from 'path-is-absolute';
/**
* 模块变量
* @private
*/
const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/;
/**
* 解析相对路径到根路径
* @param {string} rootPath - 根目录路径
* @param {string} relativePath - 相对路径
* @returns {string} 解析后的绝对路径
* @public
*/
function resolvePath(rootPath, relativePath) {
let path = relativePath;
let root = rootPath;
// root是可选的,类似于root.resolve
if (arguments.length === 1) {
path = rootPath;
root = process.cwd();
}
if (root == null) {
throw new TypeError('argument rootPath is required');
}
if (typeof root !== 'string') {
throw new TypeError('argument rootPath must be a string');
}
if (path == null) {
throw new TypeError('argument relativePath is required');
}
if (typeof path !== 'string') {
throw new TypeError('argument relativePath must be a string');
}
// 包含NULL字节是恶意的
if (path.indexOf('\0') !== -1) {
throw createError(400, 'Malicious Path');
}
// 路径绝不能是绝对路径
if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) {
throw createError(400, 'Malicious Path');
}
// 路径超出根目录
if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) {
throw createError(403);
}
// 拼接相对路径
return normalize(join(resolve(root), path));
}
export default resolvePath;

14
src/middlewares/Session/index.js

@ -0,0 +1,14 @@
import session from 'koa-session';
export default (app) => {
const CONFIG = {
key: 'koa:sess', // cookie key
maxAge: 86400000, // 1天
httpOnly: true,
signed: true,
rolling: false,
renew: false,
};
app.keys = app.keys || ['koa3-demo-session-secret'];
return session(CONFIG, app);
};

74
src/middlewares/Views/index.js

@ -0,0 +1,74 @@
import { resolve } from "path"
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"
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()
// 将 render 注入到 context 和 response 对象中
ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) {
renderOptions = assign({ includeSite: true, includeUser: false }, 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,
}
if (renderOptions.includeSite) {
otherData.$site = site
}
if (renderOptions.includeUser && ctx.state && ctx.state.user) {
otherData.$user = ctx.state.user
}
const state = assign({}, otherData, locals, options, ctx.state || {})
// deep copy partials
state.partials = assign({}, options.partials || {})
// logger.debug("render `%s` with %j", paths.rel, state)
ctx.type = "text/html"
// 如果是 html 文件,不编译直接 send 静态文件
if (isHtml(suffix) && !map) {
return send(ctx, paths.rel, {
root: path,
})
} else {
const engineName = map && map[suffix] ? map[suffix] : suffix
// 使用 engineSource 配置的渲染引擎 render
const render = engineSource[engineName]
if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`))
return render(resolve(path, paths.rel), state).then(html => {
// since pug has deprecated `pretty` option
// we'll use the `pretty` package in the meanwhile
// if (locals.pretty) {
// debug("using `pretty` package to beautify HTML")
// html = pretty(html)
// }
ctx.body = html
})
}
})
}
// 中间件执行结束
return next()
}
}
function isHtml(ext) {
return ext === "html"
}

54
src/middlewares/errorHandler/index.js

@ -0,0 +1,54 @@
// src/plugins/errorHandler.js
// 错误处理中间件插件
function formatError(ctx, status, message, stack) {
const accept = ctx.accepts('json', 'html', 'text');
const isDev = process.env.NODE_ENV === 'development';
if (accept === 'json') {
ctx.type = 'application/json';
ctx.body = isDev && stack
? { success: false, error: message, stack }
: { success: false, error: message };
} else if (accept === 'html') {
ctx.type = 'html';
ctx.body = `
<html>
<head><title>${status} Error</title></head>
<body>
<h1>${status} Error</h1>
<p>${message}</p>
${isDev && stack ? `<pre style='color:red;'>${stack}</pre>` : ''}
</body>
</html>
`;
} else {
ctx.type = 'text';
ctx.body = isDev && stack
? `${status} - ${message}\n${stack}`
: `${status} - ${message}`;
}
ctx.status = status;
}
export default function errorHandler() {
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) {
formatError(ctx, 404, 'Resource not found');
}
} catch (err) {
const isDev = process.env.NODE_ENV === 'development';
if (isDev && err.stack) {
console.error(err.stack);
}
formatError(ctx, err.statusCode || 500, err.message || err || 'Internal server error', isDev ? err.stack : undefined);
}
};
}

52
src/middlewares/install.js

@ -0,0 +1,52 @@
import ResponseTime from "./ResponseTime"
import Send from "./Send"
import { resolve } from "path"
import { fileURLToPath } from "url"
import path from "path"
import ErrorHandler from "./ErrorHandler"
import { auth } from "./Auth"
import bodyParser from "koa-bodyparser"
import Views from "./Views"
import Session from "./Session"
import { autoRegisterControllers } from "@/utils/ForRegister.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicPath = resolve(__dirname, "../../public")
export default app => {
app.use(ErrorHandler())
app.use(ResponseTime)
app.use(Session(app));
app.use(
auth({
whiteList: [
// 所有请求放行
{ pattern: "/", auth: false },
{ pattern: "/**/*", auth: false },
],
blackList: [
"/api",
"/api/",
"/api/**/*",
],
})
)
app.use(bodyParser())
app.use(
Views(resolve(__dirname, "../views"), {
extension: "pug",
options: {
basedir: resolve(__dirname, "../views"),
}
})
)
autoRegisterControllers(app)
app.use(async (ctx, next) => {
try {
await Send(ctx, ctx.path, { root: publicPath })
} catch (err) {
if (err.status !== 404) throw err
}
await next()
})
}

13
src/plugins/ResponseTime/index.js

@ -1,13 +0,0 @@
import log4js from "log4js"
const logger = log4js.getLogger()
export default async (ctx, next) => {
logger.debug("::in:: %s %s", ctx.method, ctx.path)
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set("X-Response-Time", `${ms}ms`)
const rt = ctx.response.get("X-Response-Time")
logger.debug(`::out:: takes ${rt} for ${ctx.method} ${ctx.url}`)
}

5
src/plugins/install.js

@ -1,5 +0,0 @@
import ResponseTime from "./ResponseTime";
export default (app)=>{
app.use(ResponseTime)
}

18
src/services/JobService.js

@ -0,0 +1,18 @@
import jobs from "../jobs"
class JobService {
startJob(id) {
return jobs.start(id)
}
stopJob(id) {
return jobs.stop(id)
}
updateJobCron(id, cronTime) {
return jobs.updateCronTime(id, cronTime)
}
listJobs() {
return jobs.list()
}
}
export default JobService

25
src/services/SiteConfigService.js

@ -0,0 +1,25 @@
import SiteConfigModel from "../db/models/SiteConfigModel.js"
class SiteConfigService {
// 获取单个配置
async get(key) {
return await SiteConfigModel.get(key)
}
// 设置单个配置
async set(key, value) {
return await SiteConfigModel.set(key, value)
}
// 批量获取
async getMany(keys) {
return await SiteConfigModel.getMany(keys)
}
// 获取全部配置
async getAll() {
return await SiteConfigModel.getAll()
}
}
export default SiteConfigService

74
src/services/userService.js

@ -0,0 +1,74 @@
import UserModel from "db/models/UserModel.js"
import { hashPassword, comparePassword } from "utils/bcrypt.js"
import { JWT_SECRET } from "@/middlewares/Auth/auth.js"
import jwt from "@/middlewares/Auth/jwt.js"
class UserService {
async getUserById(id) {
// 这里可以调用数据库模型
// 示例返回
return { id, name: `User_${id}` }
}
// 获取所有用户
async getAllUsers() {
return await UserModel.findAll()
}
// 创建新用户
async createUser(data) {
if (!data.name) throw new Error("用户名不能为空")
return await UserModel.create(data)
}
// 更新用户
async updateUser(id, data) {
const user = await UserModel.findById(id)
if (!user) throw new Error("用户不存在")
return await UserModel.update(id, data)
}
// 删除用户
async deleteUser(id) {
const user = await UserModel.findById(id)
if (!user) throw new Error("用户不存在")
return await UserModel.delete(id)
}
// 注册新用户
async register(data) {
if (!data.username || !data.email || !data.password) throw new Error("用户名、邮箱和密码不能为空")
const existUser = await UserModel.findByUsername(data.username)
if (existUser) throw new Error("用户名已存在")
const existEmail = await UserModel.findByEmail(data.email)
if (existEmail) throw new Error("邮箱已被注册")
// 密码加密
const hashed = await hashPassword(data.password)
const user = await UserModel.create({ ...data, password: hashed })
// 返回脱敏信息
const { password, ...userInfo } = Array.isArray(user) ? user[0] : user
return userInfo
}
// 登录
async login({ username, email, password }) {
let user
if (username) {
user = await UserModel.findByUsername(username)
} else if (email) {
user = await UserModel.findByEmail(email)
}
if (!user) throw new Error("用户不存在")
// 校验密码
const ok = await comparePassword(password, user.password)
if (!ok) throw new Error("密码错误")
// 生成token
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: "2h" })
// 返回token和用户信息
const { password: pwd, ...userInfo } = user
return { token, user: userInfo }
}
}
export default UserService

37
src/utils/BaseSingleton.js

@ -0,0 +1,37 @@
// 抽象基类,使用泛型来正确推导子类类型
class BaseSingleton {
static _instance
constructor() {
if (this.constructor === BaseSingleton) {
throw new Error("禁止直接实例化 BaseOne 抽象类")
}
if (this.constructor._instance) {
throw new Error("构造函数私有化失败,禁止重复 new")
}
// this.constructor 是子类,所以这里设为 instance
this.constructor._instance = this
}
static getInstance() {
const clazz = this
if (!clazz._instance) {
const self = new this()
const handler = {
get: function (target, prop) {
const value = Reflect.get(target, prop)
if (typeof value === "function") {
return value.bind(target)
}
return Reflect.get(target, prop)
},
}
clazz._instance = new Proxy(self, handler)
}
return clazz._instance
}
}
export { BaseSingleton }

78
src/utils/ForRegister.js

@ -0,0 +1,78 @@
// 自动扫描 controllers 目录并注册路由
// 兼容传统 routes 方式和自动注册 controller 方式
import fs from "fs"
import path from "path"
// 保证不会被摇树(tree-shaking),即使在生产环境也会被打包
if (import.meta.env.PROD) {
// 通过引用返回值,防止被摇树优化
let controllers = import.meta.glob("../controllers/**/*Controller.js", { eager: true })
controllers = null
console.log(controllers);
}
/**
* 自动扫描 controllers 目录注册所有导出的路由
* 自动检测 routes 目录下已手动注册的 controller避免重复注册
* @param {Koa} app - Koa 实例
* @param {string} controllersDir - controllers 目录路径
* @param {string} prefix - 路由前缀
* @param {Set<string>} [manualControllers] - 可选手动传入已注册 controller 文件名集合优先于自动扫描
*/
export function autoRegisterControllers(app, controllersDir = path.resolve(__dirname, "../controllers")) {
let allRouter = []
async function scan(dir, routePrefix = "") {
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)
} else if (file.endsWith("Controller.js")) {
let controller
try {
controller = require(fullPath)
} catch (e) {
controller = (await import(fullPath)).default
}
const routes = controller.createRoutes || controller.default?.createRoutes || controller.default || controller
if (typeof routes === "function") {
allRouter.push(routes())
}
}
}
}
;(async () => {
await scan(controllersDir)
// TODO: 存在问题:每个Controller都是有顺序的,如果其中一个Controller没有next方法,可能会导致后续的Controller无法执行
allRouter.forEach(router => {
app.use(router.middleware())
})
// // 聚合中间件:只分发到匹配的router
// app.use(async (ctx, next) => {
// let matched = false
// for (const router of allRouter) {
// // router._matchRoute 只在 router.js 内部,需暴露或用 middleware 包一层
// if (typeof router._matchRoute === "function") {
// const route = router._matchRoute(ctx.method.toLowerCase(), ctx.path)
// if (route) {
// matched = true
// await router.middleware()(ctx, next)
// break // 命中一个即停止
// }
// } else {
// // fallback: 直接尝试middleware,若未命中会自动next
// const before = ctx.status
// await router.middleware()(ctx, next)
// if (ctx.status !== before) {
// matched = true
// break
// }
// }
// }
// if (!matched) {
// await next()
// }
// })
})()
}

11
src/utils/bcrypt.js

@ -0,0 +1,11 @@
// 密码加密与校验工具
import bcrypt from "bcryptjs"
export async function hashPassword(password) {
const salt = await bcrypt.genSalt(10)
return bcrypt.hash(password, salt)
}
export async function comparePassword(password, hash) {
return bcrypt.compare(password, hash)
}

4
src/utils/helper.js

@ -0,0 +1,4 @@
export function formatResponse(success, data = null, error = null) {
return { success, error, data }
}

139
src/utils/router.js

@ -0,0 +1,139 @@
import { match } from 'path-to-regexp';
import compose from 'koa-compose';
import RouteAuth from './router/RouteAuth.js';
class Router {
/**
* 初始化路由实例
* @param {Object} options - 路由配置
* @param {string} options.prefix - 全局路由前缀
* @param {Object} options.auth - 全局默认auth配置可选优先级低于路由级
*/
constructor(options = {}) {
this.routes = { get: [], post: [], put: [], delete: [] };
this.middlewares = [];
this.options = Object.assign({}, this.options, options);
}
options = {
prefix: '',
auth: true,
}
/**
* 注册中间件
* @param {Function} middleware - 中间件函数
*/
use(middleware) {
this.middlewares.push(middleware);
}
/**
* 注册GET路由支持中间件链
* @param {string} path - 路由路径
* @param {Function} handler - 中间件和处理函数
* @param {Object} others - 其他参数可选
*/
get(path, handler, others) {
this._registerRoute("get", path, handler, others)
}
/**
* 注册POST路由支持中间件链
* @param {string} path - 路由路径
* @param {Function} handler - 中间件和处理函数
* @param {Object} others - 其他参数可选
*/
post(path, handler, others) {
this._registerRoute("post", path, handler, others)
}
/**
* 注册PUT路由支持中间件链
*/
put(path, handler, others) {
this._registerRoute("put", path, handler, others)
}
/**
* 注册DELETE路由支持中间件链
*/
delete(path, handler, others) {
this._registerRoute("delete", path, handler, others)
}
/**
* 创建路由组
* @param {string} prefix - 组内路由前缀
* @param {Function} callback - 组路由注册回调
*/
group(prefix, callback) {
const groupRouter = new Router({ prefix: this.options.prefix + prefix })
callback(groupRouter);
// 合并组路由到当前路由
Object.keys(groupRouter.routes).forEach(method => {
this.routes[method].push(...groupRouter.routes[method]);
});
this.middlewares.push(...groupRouter.middlewares);
}
/**
* 生成Koa中间件
* @returns {Function} Koa中间件函数
*/
middleware() {
return async (ctx, next) => {
const { method, path } = ctx;
const route = this._matchRoute(method.toLowerCase(), path);
// 组合全局中间件、路由专属中间件和 handler
const middlewares = [...this.middlewares];
if (route) {
// 如果匹配到路由,添加路由专属中间件和处理函数
ctx.params = route.params;
let isAuth = this.options.auth;
if (route.meta && route.meta.auth !== undefined) {
isAuth = route.meta.auth;
}
middlewares.push(RouteAuth({ auth: isAuth }));
middlewares.push(route.handler)
// 用 koa-compose 组合
const composed = compose(middlewares);
await composed(ctx, next);
} else {
// 如果没有匹配到路由,直接调用 next
await next();
}
};
}
/**
* 内部路由注册方法支持中间件链
* @private
*/
_registerRoute(method, path, handler, others) {
const fullPath = this.options.prefix + path
const keys = [];
const matcher = match(fullPath, { decode: decodeURIComponent });
this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others })
}
/**
* 匹配路由
* @private
*/
_matchRoute(method, currentPath) {
const routes = this.routes[method] || [];
for (const route of routes) {
const matchResult = route.matcher(currentPath);
if (matchResult) {
return { ...route, params: matchResult.params };
}
}
return null;
}
}
export default Router;

49
src/utils/router/RouteAuth.js

@ -0,0 +1,49 @@
import jwt from "@/middlewares/Auth/jwt.js"
import { JWT_SECRET } from "@/middlewares/Auth/auth.js"
/**
* 路由级权限中间件
* 支持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 {}
}
}
if (auth === "try") {
return next()
}
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
}
return next()
}
// 其他自定义模式
return next()
}
}
function getToken(ctx) {
// 只支持 Authorization: Bearer xxx
return ctx.headers["authorization"]?.replace(/^Bearer\s/i, "")
}

60
src/utils/scheduler.js

@ -0,0 +1,60 @@
import cron from 'node-cron';
class Scheduler {
constructor() {
this.jobs = new Map();
}
add(id, cronTime, task, options = {}) {
if (this.jobs.has(id)) this.remove(id);
const job = cron.createTask(cronTime, task, { ...options, noOverlap: true });
this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' });
}
execute(id) {
const entry = this.jobs.get(id);
if (entry && entry.status === 'running') {
entry.job.execute();
}
}
start(id) {
const entry = this.jobs.get(id);
if (entry && entry.status !== 'running') {
entry.job.start();
entry.status = 'running';
}
}
stop(id) {
const entry = this.jobs.get(id);
if (entry && entry.status === 'running') {
entry.job.stop();
entry.status = 'stopped';
}
}
remove(id) {
const entry = this.jobs.get(id);
if (entry) {
entry.job.destroy();
this.jobs.delete(id);
}
}
updateCronTime(id, newCronTime) {
const entry = this.jobs.get(id);
if (entry) {
this.remove(id);
this.add(id, newCronTime, entry.task, entry.options);
}
}
list() {
return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({
id, cronTime, status
}));
}
}
export default new Scheduler();

50
src/views/htmx/footer.pug

@ -0,0 +1,50 @@
.footer-panel
.footer-content
p © 2023-#{new Date().getFullYear()} #{$site.site_title}. 保留所有权利。
ul.footer-links
li
a(href="/") 首页
li
a(href="/about") 关于我们
style.
.footer-panel {
background: rgba(34,34,34,.25);
backdrop-filter: blur(12px);
color: #eee;
padding: 40px 0 24px 0;
font-size: 15px;
margin-top: 40px;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.footer-content {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
.footer-content p {
margin: 0 0 10px 0;
letter-spacing: 1px;
}
.footer-links {
list-style: none;
padding: 0;
display: flex;
gap: 24px;
}
.footer-links li {
display: inline;
}
.footer-links a {
color: #eee;
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover {
color: #4fc3f7;
text-decoration: underline;
}

4
src/views/htmx/fuck.pug

@ -0,0 +1,4 @@
if title
h1 <a href="/page/htmx">#{title}</a>
else
h1 默认标题

13
src/views/htmx/login.pug

@ -0,0 +1,13 @@
if edit
.row.justify-content-center.mt-5
.col-md-6
form#loginForm(method="post" action="/api/login" hx-post="/api/login" hx-trigger="submit" hx-target="body" hx-swap="none" hx-on:htmx:afterRequest="if(event.detail.xhr.status===200){window.location='/';}")
.mb-3
label.form-label(for="username") 用户名
input.form-control(type="text" id="username" name="username" required)
.mb-3
label.form-label(for="password") 密码
input.form-control(type="password" id="password" name="password" required)
button.btn.btn-primary(type="submit") 登录
else
div sad 404

2
src/views/htmx/navbar.pug

@ -0,0 +1,2 @@
nav.navbar
.title 首页

178
src/views/htmx/timeline.pug

@ -0,0 +1,178 @@
- var _dataList = timeLine || []
ul.time-line
each item in _dataList
li.time-line-item
.timeline-icon
div #{item.icon}
.time-line-item-content
.time-line-item-title #{item.title}
.time-line-item-desc #{item.desc}
li.time-line-item
.timeline-icon
div 第一份工作
.time-line-item-content
.time-line-item-title ???
.time-line-item-desc 做游戏的。
li.time-line-item
.timeline-icon
div 大学毕业
.time-line-item-content
.time-line-item-title 2014年09月
.time-line-item-desc 我从
a(href="https://www.jxnu.edu.cn/" target="_blank") 江西师范大学
span 毕业,获得了软件工程(虚拟现实与技术)专业的学士学位。
li.time-line-item
.timeline-icon
div 高中
.time-line-item-content
.time-line-item-title ???
.time-line-item-desc 宜春中学
li.time-line-item
.timeline-icon
div 初中
.time-line-item-content
.time-line-item-title ???
.time-line-item-desc 宜春实验中学
li.time-line-item
.timeline-icon
div 小学
.time-line-item-content
.time-line-item-title ???
.time-line-item-desc 宜春二小
li.time-line-item
.timeline-icon
div 出生
.time-line-item-content
.time-line-item-title 1996年06月
.time-line-item-desc 我出生于江西省丰城市泉港镇。
style.
.time-line {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
.time-line:before {
content: "";
width: 3px;
height: 100%;
background: rgba(255, 255, 255, 0.37);
backdrop-filter: blur(12px);
left: 50%;
top: 0;
position: absolute;
transform: translateX(-50%);
}
.time-line::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
width: 0;
height: 0;
border-top: 12px solid rgba(255, 255, 255, 0.37);
border-right: 7px solid transparent;
border-left: 7px solid transparent;
backdrop-filter: blur(12px);
transform: translateX(-50%);
}
.time-line a {
color: rgb(219, 255, 121);
text-decoration: underline;
font-weight: 600;
transition: color 0.2s, background 0.2s;
border-radius: 8px;
padding: 1px 4px;
}
.time-line a:hover {
color: #fff;
background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%);
text-decoration: none;
}
.time-line-item {
color: white;
width: 900px;
margin: 20px auto;
position: relative;
}
.time-line-item:first-child {
margin-top: 0;
}
.time-line-item:last-child {
margin-bottom: 50px;
}
.timeline-icon {
position: absolute;
width: 100px;
height: 50px;
background-color: #ee4d4d7a;
backdrop-filter: blur(12px);
left: 50%;
top: 0;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, Helvetica, sans-serif;
}
.time-line-item-title {
background-color: #ee4d4d7a;
backdrop-filter: blur(12px);
height: 50px;
line-height: 50px;
padding: 0 20px;
}
.time-line-item:nth-child(odd) .time-line-item-content {
color: white;
width: 50%;
padding-right: 80px;
}
.time-line-item:nth-child(odd) .time-line-item-content::before {
content: "";
position: absolute;
left: calc(50% - 80px);
top: 20px;
width: 0;
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 7px solid #ee4d4d7a;
backdrop-filter: blur(12px);
}
.time-line-item:nth-child(even) .time-line-item-content {
float: right;
width: 50%;
padding-left: 80px;
}
.time-line-item:nth-child(even) .time-line-item-content::before {
content: "";
position: absolute;
right: calc(50% - 80px);
top: 20px;
width: 0;
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-right: 7px solid #ee4d4d7a;
backdrop-filter: blur(12px);
}
.time-line-item-desc {
background-color: #ffffff54;
backdrop-filter: blur(12px);
color: #fff;
padding: 20px;
line-height: 1.4;
}

55
src/views/layouts/base.pug

@ -0,0 +1,55 @@
mixin include()
if block
block
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
doctype html
html(lang="zh-CN")
head
block head
title #{$site && $site.site_title || ''}
meta(name="description" content=$site && $site.site_description || '')
meta(name="keywords" content=$site && $site.keywords || '')
if $site && $site.site_favicon
+link($site.site_favicon, '')
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
+css('reset.css')
+js('lib/htmx.min.js')
//- +css('https://unpkg.com/simplebar@latest/dist/simplebar.css', true)
//- +css('simplebar-shim.css')
//- +js('https://unpkg.com/simplebar@latest/dist/simplebar.min.js', true)
body(style="--bg:url("+($site && $site.site_bg || '#fff')+")")
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%")
//- block content
//- block scripts
block content
block scripts

31
src/views/layouts/page.pug

@ -0,0 +1,31 @@
extends /layouts/base.pug
block head
+css('styles.css')
block pageHead
block content
.page-layout
.page
- const navs = [];
- navs.push({ href: '/', label: '首页' });
- navs.push({ href: '/articles', label: '文章' });
- navs.push({ href: '/article', label: '收藏' });
- navs.push({ href: '/about', label: '关于' });
nav.nav
ul.flota-nav
each nav in navs
li
a.item(
href=nav.href,
class=currentPath === nav.href ? 'active' : ''
) #{nav.label}
.content
block pageContent
footer
+include()
- var edit = false
include /htmx/footer.pug
block scripts
block pageScripts

18
src/views/layouts/pure.pug

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

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

@ -0,0 +1,100 @@
extends /layouts/pure.pug
block pageContent
.about-container
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") 联系方式
| 与我们取得联系。
style.
.about-container {
margin: 32px auto 0 auto;
padding: 56px 40px 44px 40px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(18px);
//- max-width: 900px;
//- min-width: 340px;
}
.about-container h1 {
font-size: 2.7em;
color: rgb(80, 168, 255);
margin-bottom: 28px;
text-align: center;
font-weight: 900;
letter-spacing: 2.5px;
text-shadow: 0 2px 16px rgba(33,150,243,0.10);
background: linear-gradient(90deg,rgb(80, 168, 255) 30%, #7ec6f7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.about-container p {
font-size: 1.18em;
color: #fff;
margin-bottom: 26px;
line-height: 1.85;
}
.about-section {
margin-bottom: 38px;
padding-bottom: 14px;
border-bottom: 1px dashed #b2ebf2;
}
.about-section:last-child {
border-bottom: none;
}
.about-section h2 {
font-size: 1.22em;
color:rgb(80, 168, 255);
margin-bottom: 10px;
font-weight: 700;
letter-spacing: 1.2px;
background: linear-gradient(90deg,rgb(80, 168, 255) 60%, #7ec6f7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.about-section ul {
padding-left: 28px;
margin-bottom: 0;
}
.about-section li {
font-size: 1.08em;
color: #fff;
margin-bottom: 8px;
line-height: 1.7;
}
.about-container a {
color:rgb(80, 168, 255);
text-decoration: underline;
font-weight: 600;
transition: color 0.2s, background 0.2s;
border-radius: 8px;
padding: 1px 4px;
}
.about-container a:hover {
color: #fff;
background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%);
text-decoration: none;
}
@media (max-width: 900px) {
.about-container {
max-width: 98vw;
padding: 18px 4vw 18px 4vw;
}
.about-container h1 {
font-size: 1.5em;
}
}

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

@ -0,0 +1,113 @@
extends /layouts/page.pug
block pageContent
.article-list-container-full
- const articles = []
- articles.push({ id: 1, title: '文章标题1', author: '作者1', created_at: '2023-08-01', summary: '这是文章摘要...' })
- articles.push({ id: 2, title: '文章标题2', author: '作者2', created_at: '2023-08-02', summary: '这是另一篇文章摘要...' })
//- 文章列表
if articles && articles.length
each article in articles
.article-item-full
h2.article-title-full
a(href=`/articles/${article.id}`) #{article.title}
.article-meta-full
span 作者:#{article.author} | 发布时间:#{article.created_at}
p.article-summary-full #{article.summary}
else
p.no-articles 暂无文章
//- 分页控件
if totalPages > 1
.pagination-full
if page > 1
a.page-btn-full(href=`?page=${page-1}`) 上一页
else
span.page-btn-full.disabled 上一页
span.page-info-full 第 #{page} / #{totalPages} 页
if page < totalPages
a.page-btn-full(href=`?page=${page+1}`) 下一页
else
span.page-btn-full.disabled 下一页
style.
.article-list-container-full {
width: 100%;
max-width: 100%;
margin: 40px 0 0 0;
background: transparent;
border-radius: 0;
box-shadow: none;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.article-item-full {
width: 90vw;
max-width: 1200px;
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 16px #e0e7ef;
margin-bottom: 28px;
padding: 28px 36px 18px 36px;
border-left: 6px solid #6dd5fa;
transition: box-shadow 0.2s, border-color 0.2s;
}
.article-item-full:hover {
box-shadow: 0 4px 32px #cbe7ff;
border-left: 6px solid #ff6a88;
}
.article-title-full {
margin: 0 0 8px 0;
font-size: 1.6em;
}
.article-title-full a {
color: #2b7cff;
text-decoration: none;
transition: color 0.2s;
}
.article-title-full a:hover {
color: #ff6a88;
}
.article-meta-full {
color: #888;
font-size: 1em;
margin-bottom: 8px;
}
.article-summary-full {
color: #444;
font-size: 1.13em;
}
.no-articles {
text-align: center;
color: #aaa;
margin: 40px 0;
}
.pagination-full {
display: flex;
justify-content: center;
align-items: center;
margin: 32px 0 0 0;
gap: 18px;
}
.page-btn-full {
padding: 7px 22px;
border-radius: 22px;
background: linear-gradient(90deg, #6dd5fa, #ff6a88);
color: #fff;
text-decoration: none;
font-weight: bold;
transition: background 0.2s;
cursor: pointer;
font-size: 1.08em;
}
.page-btn-full.disabled {
background: #eee;
color: #bbb;
cursor: not-allowed;
pointer-events: none;
}
.page-info-full {
color: #666;
font-size: 1.12em;
}

54
src/views/page/auth/no-auth.pug

@ -0,0 +1,54 @@
extends /layouts/page.pug
block pageContent
.no-auth-container
.no-auth-icon
i.fa.fa-lock
h2 访问受限
p 您没有权限访问此页面,请先登录或联系管理员。
a.btn(href='/login') 去登录
block pageHead
style.
.no-auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.no-auth-icon {
font-size: 4em;
color: #ff6a6a;
margin-bottom: 20px;
}
.no-auth-container h2 {
margin: 0 0 10px 0;
color: #333;
}
.no-auth-container p {
color: #888;
margin-bottom: 24px;
}
.no-auth-container .btn {
display: inline-block;
padding: 10px 32px;
background: linear-gradient(90deg, #4fd1ff 0%, #ff6a6a 100%);
color: #fff;
border: none;
border-radius: 24px;
font-size: 1.1em;
text-decoration: none;
transition: background 0.2s;
cursor: pointer;
}
.no-auth-container .btn:hover {
background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%);
color: #fff200;
}
block pageScripts
script.
const curUrl = URL.parse(location.href).searchParams.get("from")
fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e))

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

@ -0,0 +1,10 @@
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}

15
src/views/page/index/index.pug

@ -0,0 +1,15 @@
extends /layouts/pure.pug
block pageHead
+css("css/page/index.css")
block pageContent
.home-hero
.avatar-container
.author #{$site.site_author}
img.avatar(src="https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg", alt="")
.card
div 人生轨迹
+include()
- let timeLine = [{icon: '11',title: "aaaa",desc:"asd"}]
include /htmx/timeline.pug

9
src/views/page/index/person.pug

@ -0,0 +1,9 @@
extends /layouts/pure.pug
block pageHead
+css("css/page/index.css")
block pageContent
+include()
- let timeLine = [{icon: '11',title: "aaaa",desc:"asd"}]
include /htmx/timeline.pug

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

@ -0,0 +1,116 @@
extends /layouts/pure.pug
block pageScripts
script(src="js/login.js")
block pageContent
.login-container
.login-card
span.back-home-text
a(href="/", title="返回首页") 返回首页
h2 登录
form#login-form(action="/login" method="post")
.form-group
label(for="username") 用户名
input#username(type="text" name="username" placeholder="请输入用户名" required)
.form-group
label(for="password") 密码
input#password(type="password" name="password" placeholder="请输入密码" required)
button.login-btn(type="submit") 登录
if error
.login-error= error
// 页面内联样式优化
style.
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.login-card {
background: #fff;
border-radius: 24px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
padding: 2.5rem 2.5rem 2rem 2.5rem;
min-width: 380px;
max-width: 96vw;
width: 420px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.back-home-text {
position: absolute;
left: 24px;
top: 18px;
font-size: 0.98rem;
color: #bbb;
font-weight: 500;
letter-spacing: 1px;
}
.back-home-text a {
color: #bbb;
text-decoration: none;
transition: color 0.2s;
}
.back-home-text a:hover {
color: #7ec6f7;
text-decoration: underline;
}
.login-card h2 {
margin-bottom: 1.5rem;
font-weight: 600;
color: #333;
letter-spacing: 2px;
}
.form-group {
width: 100%;
margin-bottom: 1.4rem;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.form-group label {
margin-bottom: 0.5rem;
color: #444;
font-size: 1.13rem;
font-weight: 600;
letter-spacing: 1px;
}
.form-group input {
width: 100%;
padding: 0.7rem 1.1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1.08rem;
outline: none;
transition: border 0.2s;
}
.form-group input:focus {
border-color: #7ec6f7;
}
.login-btn {
width: 100%;
padding: 0.8rem 0;
border: none;
border-radius: 20px;
background: linear-gradient(135deg, #7ec6f7 0%, #ff8ca8 100%);
color: #fff;
font-size: 1.13rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(126,198,247,0.12);
transition: background 0.2s, box-shadow 0.2s;
margin-top: 0.7rem;
}
.login-btn:hover {
background: linear-gradient(135deg, #5bb0e6 0%, #ff6f91 100%);
box-shadow: 0 4px 16px rgba(255,140,168,0.12);
}
.login-error {
color: #ff4d4f;
margin-top: 1rem;
font-size: 0.98rem;
text-align: center;
}

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

@ -0,0 +1,94 @@
doctype html
html
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title 注册
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;
}
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;
body
.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="email") 邮箱
input(type="email" id="email" name="email" 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="请再次输入密码")
button.register-btn(type="submit") 注册
a.login-link(href="/login") 已有账号?去登录

83
vite.config.ts

@ -0,0 +1,83 @@
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import module from "node:module"
import { defineConfig } from "vite"
import pkg from "./package.json"
import { viteStaticCopy } from "vite-plugin-static-copy"
const __dirname = dirname(fileURLToPath(import.meta.url))
function getExternal(): string[] {
return [...Object.keys(pkg.dependencies || {}), ...module.builtinModules]
}
export default defineConfig({
publicDir: false,
resolve: {
alias: {
"@": resolve(__dirname, "src"),
db: resolve(__dirname, "src/db"),
config: resolve(__dirname, "src/config"),
utils: resolve(__dirname, "src/utils"),
services: resolve(__dirname, "src/services"),
},
},
build: {
lib: {
entry: resolve(__dirname, "src/main.js"),
formats: ["es"],
fileName: () => `[name].js`,
},
outDir: resolve(__dirname, "dist"),
rollupOptions: {
external: getExternal(),
// watch: {
// include: "src/**",
// exclude: "node_modules/**",
// },
output: {
preserveModules: true,
preserveModulesRoot: "src",
inlineDynamicImports: false,
},
},
},
plugins: [
viteStaticCopy({
targets: [
{
src: "public",
dest: "",
},
{
src: "src/views",
dest: "",
},
{
src: "src/db/migrations",
dest: "db",
},
{
src: "src/db/seeds",
dest: "db",
},
{
src: "entrypoint.sh",
dest: "",
},
{
src: "package.json",
dest: "",
},
{
src: "knexfile.mjs",
dest: "",
},
{
src: "bun.lockb",
dest: "",
},
],
}),
],
})
Loading…
Cancel
Save