Compare commits

...

12 Commits
main ... alpha

Author SHA1 Message Date
npmrun 9d1dfa3715 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. 18
      entrypoint.sh
  13. 26
      jsconfig.json
  14. 17
      package.json
  15. 1
      public/static/aa.txt
  16. 19
      public/styles.css
  17. 56
      src/controllers/JobController.js
  18. 15
      src/controllers/Page/HtmxController.js
  19. 14
      src/controllers/Page/PageController.js
  20. 16
      src/controllers/StatusController.js
  21. 48
      src/controllers/userController.js
  22. 23
      src/controllers/userController.mjs
  23. 5
      src/db/migrations/20250616065041_create_users_table.mjs
  24. 12
      src/db/models/UserModel.js
  25. 6
      src/db/seeds/20250616071157_users_seed.mjs
  26. 11
      src/jobs/exampleJob.js
  27. 48
      src/jobs/index.js
  28. 52
      src/logger.js
  29. 33
      src/main.js
  30. 71
      src/middlewares/Auth/auth.js
  31. 3
      src/middlewares/Auth/index.js
  32. 3
      src/middlewares/Auth/jwt.js
  33. 63
      src/middlewares/ResponseTime/index.js
  34. 185
      src/middlewares/Send/index.js
  35. 74
      src/middlewares/Send/resolve-path.js
  36. 57
      src/middlewares/Views/index.js
  37. 54
      src/middlewares/errorHandler/index.js
  38. 51
      src/middlewares/install.js
  39. 13
      src/plugins/ResponseTime/index.js
  40. 5
      src/plugins/install.js
  41. 18
      src/services/JobService.js
  42. 74
      src/services/userService.js
  43. 37
      src/utils/BaseSingleton.js
  44. 44
      src/utils/autoRegister.js
  45. 11
      src/utils/bcrypt.js
  46. 4
      src/utils/helper.js
  47. 134
      src/utils/router.js
  48. 50
      src/utils/router/RouteAuth.js
  49. 60
      src/utils/scheduler.js
  50. 4
      src/views/htmx/fuck.pug
  51. 13
      src/views/htmx/login.pug
  52. 2
      src/views/htmx/navbar.pug
  53. 15
      src/views/layouts/base.pug
  54. 14
      src/views/layouts/page.pug
  55. 8
      src/views/page/index/index.pug

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.

18
entrypoint.sh

@ -0,0 +1,18 @@
#!/bin/sh
set -e
# 数据库文件路径(可根据实际环境调整)
DB_FILE=./database/db.sqlite3
# 如果数据库文件不存在,先 migrate 再 seed
if [ ! -f "$DB_FILE" ]; then
echo "Database not found, running migration and seed..."
bun run npx knex migrate:latest
bun run npx knex seed:run
else
echo "Database exists, running migration only..."
bun run npx knex migrate:latest
fi
# 启动主服务
exec bun src/main.js

26
jsconfig.json

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

17
package.json

@ -12,17 +12,28 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"knex": "^3.1.0"
"@types/node": "^24.0.1"
},
"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",
"log4js": "^6.9.1",
"minimatch": "^9.0.0",
"module-alias": "^2.2.3",
"node-cron": "^4.1.0",
"path-to-regexp": "^8.2.0",
"pug": "^3.0.3",
"sqlite3": "^5.1.7"
},
"_moduleAliases": {
"@": "./src",
"db": "./src/db"
"db": "./src/db",
"utils": "./src/utils",
"services": "./src/services"
}
}

1
public/static/aa.txt

@ -0,0 +1 @@
asd

19
public/styles.css

@ -0,0 +1,19 @@
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
.navbar {
height: 49px;
display: flex;
align-items: center;
box-shadow: 1px 1px 3px #e4e4e4;
}
.title{
font-size: 1.5em;
margin-left: 10px;
color: #333;
}

56
src/controllers/JobController.js

@ -0,0 +1,56 @@
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
import JobService from "services/JobService.js"
import { formatResponse } from "utils/helper.js"
const jobService = new JobService()
export const list = async (ctx) => {
try {
const data = jobService.listJobs()
ctx.body = formatResponse(true, data)
} catch (err) {
ctx.body = formatResponse(false, null, err.message || "获取任务列表失败")
}
}
export const start = async (ctx) => {
const { id } = ctx.params
try {
jobService.startJob(id)
ctx.body = formatResponse(true, null, null, `${id} 任务已启动`)
} catch (err) {
ctx.body = formatResponse(false, null, err.message || "启动任务失败")
}
}
export const stop = async (ctx) => {
const { id } = ctx.params
try {
jobService.stopJob(id)
ctx.body = formatResponse(true, null, null, `${id} 任务已停止`)
} catch (err) {
ctx.body = formatResponse(false, null, err.message || "停止任务失败")
}
}
export const updateCron = async (ctx) => {
const { id } = ctx.params
const { cronTime } = ctx.request.body
try {
jobService.updateJobCron(id, cronTime)
ctx.body = formatResponse(true, null, null, `${id} 任务频率已修改`)
} catch (err) {
ctx.body = formatResponse(false, null, err.message || "修改任务频率失败")
}
}
// 路由注册示例
import Router from "utils/router.js"
export function createRoutes() {
const router = new Router({ prefix: "/api/jobs", auth: true })
router.get("/", list)
router.post("/start/:id", start)
router.post("/stop/:id", stop)
router.post("/update/:id", updateCron)
return router
}

15
src/controllers/Page/HtmxController.js

@ -0,0 +1,15 @@
export const Index = async ctx => {
return await ctx.render("index", { name: "bluescurry" })
}
export const Page = (name, data) => async ctx => {
return await ctx.render(name, data)
}
import Router from "utils/router"
export function createRoutes() {
const router = new Router({ auth: "try" })
// 如有页面路由可继续添加
// router.get("/htmx", Index)
return router
}

14
src/controllers/Page/PageController.js

@ -0,0 +1,14 @@
export const Index = async ctx => {
return await ctx.render("page/index/index", { title: "沧源一场" })
}
export const Page = (name, data) => async ctx => {
return await ctx.render(name, data)
}
import Router from "utils/router"
export function createRoutes() {
const router = new Router({ auth: "try" })
router.get("/", Index)
return router
}

16
src/controllers/StatusController.js

@ -0,0 +1,16 @@
import { formatResponse } from "utils/helper.js"
export const status = async (ctx) => {
ctx.body = "OK"
}
import Router from "utils/router.js"
export function createRoutes() {
const v1 = new Router({ prefix: "/api/v1" })
v1.use((ctx, next) => {
ctx.set("X-API-Version", "v1")
return next()
})
v1.get("/status", { auth: "try" }, status)
return v1
}

48
src/controllers/userController.js

@ -0,0 +1,48 @@
import UserService from "services/UserService.js"
import { formatResponse } from "utils/helper.js"
const userService = new UserService()
export const hello = async (ctx) => {
ctx.body = formatResponse(true, "Hello World")
}
export const getUser = async (ctx) => {
const user = await userService.getUserById(ctx.params.id)
ctx.body = formatResponse(true, user)
}
export const register = async (ctx) => {
try {
const { username, email, password } = ctx.request.body
const user = await userService.register({ username, email, password })
ctx.body = formatResponse(true, user)
} catch (err) {
ctx.body = formatResponse(false, null, err.message)
}
}
export const login = async (ctx) => {
try {
const { username, email, password } = ctx.request.body
const result = await userService.login({ username, email, password })
if (result && result.user) {
// 登录成功后写入 session,不再设置 jwt 到 cookie
ctx.session.user = result.user
}
ctx.body = formatResponse(true, result)
} catch (err) {
ctx.body = formatResponse(false, null, err.message)
}
}
// 路由注册示例
import Router from "utils/router.js"
export function createRoutes() {
const router = new Router({ prefix: "/api", auth: false })
router.get("/hello", hello)
router.get("/user/:id", { auth: true }, getUser)
router.post("/register", register)
router.post("/login", login)
return router
}

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

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()) // 更新时间

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

6
src/db/seeds/20250616071157_users_seed.mjs

@ -1,7 +1,7 @@
export const seed = async knex => {
// 检查表是否存在
const tables = await knex.raw(`
SELECT name FROM sqlite_master WHERE type='table' AND name='users'
SELECT name FROM sqlite_master WHERE type='table' AND username='users'
`)
if (tables.length === 0) {
@ -13,7 +13,7 @@ export const seed = async knex => {
// Inserts seed entries
await knex("users").insert([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
// { username: "Alice", email: "alice@example.com" },
// { username: "Bob", email: "bob@example.com" },
])
}

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

33
src/main.js

@ -1,27 +1,25 @@
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 +32,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

71
src/middlewares/Auth/auth.js

@ -0,0 +1,71 @@
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 (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缺失或无效" }
}
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;

57
src/middlewares/Views/index.js

@ -0,0 +1,57 @@
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"
export default viewsMiddleware
function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) {
return function views(ctx, next) {
if (ctx.render) return next()
// 将 render 注入到 context 和 response 对象中
ctx.response.render = ctx.render = function (relPath, locals = {}) {
return getPaths(path, relPath, extension).then(paths => {
const suffix = paths.ext
const state = Object.assign(locals, options, ctx.state || {})
// deep copy partials
state.partials = Object.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);
}
};
}

51
src/middlewares/install.js

@ -0,0 +1,51 @@
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 { autoRegisterControllers } from "utils/autoRegister.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(
auth({
whiteList: [
// // API接口访问
// "/api/login",
// "/api/register",
// { pattern: "/api/v1/status", auth: "try" },
// { pattern: "/api/**/*", auth: true },
// // 静态资源访问
// { pattern: "/", auth: "try" },
// { pattern: "/**/*", auth: "try" },
],
blackList: [],
})
)
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

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 }

44
src/utils/autoRegister.js

@ -0,0 +1,44 @@
// 自动扫描 controllers 目录并注册路由
// 兼容传统 routes 方式和自动注册 controller 方式
import fs from "fs"
import path from "path"
/**
* 自动扫描 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
// 判断 routes 方法参数个数,支持自动适配
if (typeof routes === "function") {
allRouter.push(routes())
}
}
}
}
;(async () => {
await scan(controllersDir)
allRouter.forEach(router => {
app.use(router.middleware())
})
})()
}

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

134
src/utils/router.js

@ -0,0 +1,134 @@
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.prefix = options.prefix || '';
this.routes = { get: [], post: [], put: [], delete: [] };
this.middlewares = [];
this.defaultAuth = options.auth !== undefined ? options.auth : true;
}
/**
* 注册中间件
* @param {Function} middleware - 中间件函数
*/
use(middleware) {
this.middlewares.push(middleware);
}
/**
* 注册GET路由支持中间件链
* @param {string} path - 路由路径
* @param {...Function|Object} handlers - 中间件和处理函数支持最后一个参数为auth配置对象
*/
get(path, ...handlers) {
this._registerRoute('get', path, handlers);
}
/**
* 注册POST路由支持中间件链
* @param {string} path - 路由路径
* @param {...Function|Object} handlers - 中间件和处理函数支持最后一个参数为auth配置对象
*/
post(path, ...handlers) {
this._registerRoute('post', path, handlers);
}
/**
* 注册PUT路由支持中间件链
*/
put(path, ...handlers) {
this._registerRoute('put', path, handlers);
}
/**
* 注册DELETE路由支持中间件链
*/
delete(path, ...handlers) {
this._registerRoute('delete', path, handlers);
}
/**
* 创建路由组
* @param {string} prefix - 组内路由前缀
* @param {Function} callback - 组路由注册回调
*/
group(prefix, callback) {
const groupRouter = new Router({ prefix: this.prefix + prefix, auth: this.defaultAuth });
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);
// 组合全局中间件、默认RouteAuth、路由专属中间件和 handler
const middlewares = [...this.middlewares];
if (route) {
ctx.params = route.params;
// 路由级auth优先,其次Router构造参数auth,最后默认true
let routeAuthConfig = this.defaultAuth;
if (route.handlers.length && typeof route.handlers[0] === 'object' && route.handlers[0].hasOwnProperty('auth')) {
routeAuthConfig = { ...route.handlers[0] };
route.handlers = route.handlers.slice(1);
} else if (typeof routeAuthConfig !== 'object') {
routeAuthConfig = { auth: routeAuthConfig };
}
middlewares.push(RouteAuth(routeAuthConfig));
middlewares.push(...route.handlers);
} else {
// 未命中路由也加默认RouteAuth
let defaultAuthConfig = typeof this.defaultAuth === 'object' ? this.defaultAuth : { auth: this.defaultAuth };
middlewares.push(RouteAuth(defaultAuthConfig));
}
const composed = compose(middlewares);
await composed(ctx, next);
};
}
/**
* 内部路由注册方法支持中间件链
* @private
*/
_registerRoute(method, path, handlers) {
const fullPath = this.prefix + path;
const keys = [];
const matcher = match(fullPath, { decode: decodeURIComponent });
this.routes[method].push({ path: fullPath, matcher, keys, handlers });
}
/**
* 匹配路由
* @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;

50
src/utils/router/RouteAuth.js

@ -0,0 +1,50 @@
import jwt from "./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, roles } = 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) {
ctx.status = 401;
ctx.body = { success: false, error: "未登录或Token无效" };
return;
}
if (roles && !roles.includes(ctx.state.user.role)) {
ctx.status = 403;
ctx.body = { success: false, error: "无权限" };
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();

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 首页

15
src/views/layouts/base.pug

@ -0,0 +1,15 @@
mixin include()
if block
block
doctype html
html(lang="zh-CN")
head
block head
title #{title || ''}
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
script(src="https://unpkg.com/htmx.org@2.0.4")
body
block content
block scripts

14
src/views/layouts/page.pug

@ -0,0 +1,14 @@
extends /layouts/base.pug
block head
link(rel='stylesheet', href='styles.css')
block pageHead
block content
+include()
include /htmx/navbar.pug
block pageContent
block scripts
block pageScripts

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

@ -0,0 +1,8 @@
extends /layouts/page.pug
//- +include()
//- - var edit = false
//- include /htmx/login.pug
block pageContent
div adsd
Loading…
Cancel
Save