Compare commits
23 Commits
Author | SHA1 | Date |
---|---|---|
|
a302c2e836 | 1 month ago |
|
aeb2b4ea67 | 1 month ago |
|
914b05192f | 1 month ago |
|
07a5b2ff22 | 1 month ago |
|
272664295e | 1 month ago |
|
2c3d6c86b7 | 1 month ago |
|
dcfa188b85 | 1 month ago |
|
b391dcc998 | 2 months ago |
|
d06982da2b | 2 months ago |
|
ed60efbaf8 | 2 months ago |
|
ea8a70c43d | 2 months ago |
|
d079341238 | 2 months ago |
|
e7425ec594 | 2 months ago |
|
fddb11d84f | 2 months ago |
|
07dc21c1f7 | 2 months ago |
|
9611e33b82 | 2 months ago |
|
f7dc33873d | 2 months ago |
|
8aaf9b5cd4 | 2 months ago |
|
1d142c3900 | 2 months ago |
|
c073c46410 | 2 months ago |
|
838dbbd406 | 2 months ago |
|
7d395f02bf | 2 months ago |
|
d2e8df87f3 | 2 months ago |
80 changed files with 4471 additions and 83 deletions
@ -0,0 +1,2 @@ |
|||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references |
|||
.specstory/** |
@ -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. |
File diff suppressed because it is too large
@ -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"] |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
@ -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" |
|||
] |
|||
} |
@ -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; |
|||
} |
|||
} |
@ -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 || "登录失败") |
|||
} |
|||
} |
File diff suppressed because one or more lines are too long
@ -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 |
|||
} |
@ -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; |
|||
} |
@ -0,0 +1 @@ |
|||
asd |
After Width: | Height: | Size: 1.6 MiB |
@ -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; |
|||
} |
|||
} |
@ -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(); |
|||
}); |
@ -0,0 +1,3 @@ |
|||
export default { |
|||
base: "/", |
|||
} |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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() |
|||
} |
@ -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") // 回滚时删除表
|
|||
} |
@ -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 } |
@ -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" },
|
|||
// ])
|
|||
} |
|||
|
@ -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: '/' } |
|||
]); |
|||
}; |
@ -0,0 +1,11 @@ |
|||
import { jobLogger } from "@/logger"; |
|||
|
|||
export default { |
|||
id: 'example', |
|||
cronTime: '*/10 * * * * *', // 每10秒执行一次
|
|||
task: () => { |
|||
jobLogger.info('Example Job 执行了'); |
|||
}, |
|||
options: {}, |
|||
autoStart: false |
|||
}; |
@ -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); |
|||
} |
|||
} |
|||
}; |
@ -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() |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
// 统一导出所有中间件
|
|||
import auth from "./auth.js" |
|||
export { auth } |
@ -0,0 +1,3 @@ |
|||
// 兼容性导出,便于后续扩展
|
|||
import jwt from "jsonwebtoken" |
|||
export default jwt |
@ -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") |
|||
} |
|||
} |
@ -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; |
@ -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; |
@ -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); |
|||
}; |
@ -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" |
|||
} |
@ -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); |
|||
} |
|||
}; |
|||
} |
@ -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() |
|||
}) |
|||
} |
@ -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}`) |
|||
} |
@ -1,5 +0,0 @@ |
|||
import ResponseTime from "./ResponseTime"; |
|||
|
|||
export default (app)=>{ |
|||
app.use(ResponseTime) |
|||
} |
@ -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 |
@ -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 |
@ -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 |
@ -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 } |
@ -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()
|
|||
// }
|
|||
// })
|
|||
})() |
|||
} |
@ -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) |
|||
} |
@ -0,0 +1,4 @@ |
|||
|
|||
export function formatResponse(success, data = null, error = null) { |
|||
return { success, error, data } |
|||
} |
@ -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; |
@ -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, "") |
|||
} |
@ -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(); |
@ -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; |
|||
} |
@ -0,0 +1,4 @@ |
|||
if title |
|||
h1 <a href="/page/htmx">#{title}</a> |
|||
else |
|||
h1 默认标题 |
@ -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 |
@ -0,0 +1,2 @@ |
|||
nav.navbar |
|||
.title 首页 |
@ -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; |
|||
} |
@ -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 |
@ -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 |
@ -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 |
@ -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; |
|||
} |
|||
} |
@ -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; |
|||
} |
@ -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)) |
@ -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} |
|||
|
@ -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 |
@ -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 |
@ -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; |
|||
} |
@ -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") 已有账号?去登录 |
@ -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…
Reference in new issue