Browse Source
- Implemented ResponseTime middleware to log response times for non-static and API requests. - Created Send middleware for serving static files with support for Brotli and Gzip compression. - Added resolve-path utility to prevent path traversal attacks in Send middleware. - Introduced Session middleware for managing user sessions with configurable options. - Installed middleware in the application with proper routing and error handling. - Developed JobController and JobService for managing scheduled jobs with CRUD operations. - Created UploadController for handling file uploads with support for multiple file types. - Added environment variable validation utility to ensure required and optional variables are set. - Implemented a response utility for consistent API responses. - Developed custom error classes for better error handling in the application. - Updated TypeScript configuration for improved module resolution. - Added script to fix type declaration issues in vue-router.mono
65 changed files with 2900 additions and 134 deletions
@ -1,3 +1,8 @@ |
|||
# 基于koa实现的简易ssr |
|||
|
|||
- https://segmentfault.com/a/1190000042389086 |
|||
- https://segmentfault.com/a/1190000042389086 |
|||
|
|||
|
|||
## 试试grpc,实现node与python通信,扩展更多的功能。 |
|||
https://grpc.org.cn/docs/languages/node/quickstart/ |
|||
https://www.doubao.com/chat/23869592666505474 |
|||
Binary file not shown.
@ -0,0 +1,10 @@ |
|||
import { useSSRContext } from "vue" |
|||
|
|||
export function useShareCache(): Map<string, any> | null { |
|||
if (typeof window === 'undefined') { |
|||
const ssrContext = useSSRContext() |
|||
return ssrContext?.cache || null |
|||
} else { |
|||
return (window as any).__SSR_CONTEXT__?.cache || null |
|||
} |
|||
} |
|||
@ -1,17 +1,10 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Vite + Vue + TS</title> |
|||
<html> |
|||
<head> |
|||
<!--app-head--> |
|||
</head> |
|||
|
|||
<body> |
|||
</head> |
|||
<body> |
|||
<div id="app"><!--app-html--></div> |
|||
<script type="module" src="./src/entry-client.ts"></script> |
|||
</body> |
|||
|
|||
</html> |
|||
<script type="module" src="/src/entry-client.ts"></script> |
|||
</body> |
|||
</html> |
|||
|
|||
@ -0,0 +1,48 @@ |
|||
/* 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; |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
<template> |
|||
<div ref="editorRef" style="height: 600px"></div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineOptions({ name: "QuillEditor_Editor" }); |
|||
|
|||
const props = defineProps<{ |
|||
modelValue?: string; |
|||
}>(); |
|||
const emits = defineEmits<{ |
|||
(e: "update:modelValue", value: string): void; |
|||
}>(); |
|||
|
|||
const state = reactive({ |
|||
isInnerChange: false, |
|||
isOutChange: true |
|||
}); |
|||
|
|||
import { useQuill } from "./useQuill"; |
|||
|
|||
const editorRef = useTemplateRef<HTMLElement>("editorRef"); |
|||
|
|||
const { getEditor, setContent, isReadyPromise } = useQuill({ |
|||
el: editorRef, |
|||
quillOptions: { |
|||
placeholder: "Compose an epic...", |
|||
}, |
|||
onTextChange(delta, oldDelta, source) { |
|||
// 如果是外部变化,不需要触发更新 |
|||
if(state.isOutChange) { |
|||
state.isOutChange = false; |
|||
return; |
|||
} |
|||
// 内部变化,更新外部数据 |
|||
state.isInnerChange = true; |
|||
emits("update:modelValue", getEditor()?.root.innerHTML || ""); |
|||
}, |
|||
async handleImageUpload(files: FileList) { |
|||
const formData = new FormData(); |
|||
[...files].forEach((file) => { |
|||
formData.append("file", file); |
|||
}); |
|||
const res = await ( |
|||
await fetch("/upload", { |
|||
method: "POST", |
|||
headers: { |
|||
contentType: "multipart/form-data", |
|||
}, |
|||
body: formData, |
|||
}) |
|||
).json(); |
|||
return res.urls.map((url: string) => location.origin + url); |
|||
}, |
|||
}); |
|||
watch( |
|||
() => props.modelValue, |
|||
async (newVal) => { |
|||
// 如果内部变化,不需要重新设值 |
|||
if (state.isInnerChange) { |
|||
state.isInnerChange = false; |
|||
return; |
|||
} |
|||
// 外部变化,更新编辑器内容 |
|||
state.isOutChange = true; |
|||
await isReadyPromise; |
|||
setContent(newVal || ""); |
|||
}, |
|||
{ |
|||
immediate: true, |
|||
} |
|||
); |
|||
</script> |
|||
@ -0,0 +1,10 @@ |
|||
<template> |
|||
<ClientOnly> |
|||
<Editor v-bind="$attrs" /> |
|||
</ClientOnly> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineOptions({ name: "QuillEditor", inheritAttrs: false }); |
|||
const Editor = defineAsyncComponent(() => import("./_Editor.vue")); |
|||
</script> |
|||
@ -0,0 +1,135 @@ |
|||
import { QuillOptions } from "quill"; |
|||
import Quill from "./quill-shim" |
|||
// import "quill/dist/quill.core.css";
|
|||
import "quill/dist/quill.snow.css"; |
|||
import Toolbar from "quill/modules/toolbar"; |
|||
|
|||
interface IOption { |
|||
el: string | Ref<HTMLElement> | Readonly<ShallowRef<HTMLElement | null>>; |
|||
onTextChange?: (delta: any, oldDelta: any, source: any) => void; |
|||
quillOptions?: QuillOptions; |
|||
handleImageUpload?: (file: FileList) => Promise<string | string[]>; |
|||
} |
|||
|
|||
const defalutOption: Partial<IOption> = { |
|||
quillOptions: { |
|||
placeholder: "Compose an epic...", |
|||
modules: { |
|||
toolbar: [ |
|||
["bold", "italic", "underline", "strike"], // toggled buttons
|
|||
["blockquote", "code-block"], |
|||
["link", "image", "video", "formula"], |
|||
|
|||
[{ header: 1 }, { header: 2 }], // custom button values
|
|||
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }], |
|||
[{ script: "sub" }, { script: "super" }], // superscript/subscript
|
|||
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
|
|||
[{ direction: "rtl" }], // text direction
|
|||
|
|||
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
|
|||
[{ header: [1, 2, 3, 4, 5, 6, false] }], |
|||
|
|||
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
|
|||
[{ font: [] }], |
|||
[{ align: [] }], |
|||
|
|||
["clean"], |
|||
], |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
export function useQuill(option: IOption) { |
|||
option = { ...defalutOption, ...option, quillOptions: Object.assign({}, defalutOption.quillOptions, option.quillOptions) }; |
|||
|
|||
let editor: Quill | null = null; |
|||
const onTextChange = option.onTextChange || (() => { }); |
|||
|
|||
let ReadyResolve: Function |
|||
const isReadyPromise = new Promise<Quill>((resolve,) => { |
|||
ReadyResolve = resolve; |
|||
}); |
|||
|
|||
function setContent(content: string) { |
|||
if (editor) { |
|||
editor.root.innerHTML = content; |
|||
} |
|||
} |
|||
|
|||
function init(option: IOption) { |
|||
if (editor) return; |
|||
if (!option.el) return; |
|||
if (typeof option.el !== "string" && !option.el.value) return; |
|||
editor = new Quill(typeof option.el === "string" ? option.el : option.el.value!, { |
|||
theme: "snow", |
|||
...(option.quillOptions || {}), |
|||
}); |
|||
ReadyResolve?.(editor); |
|||
editor.on("text-change", onTextChange); |
|||
|
|||
const toolbar = editor.getModule('toolbar') as Toolbar; |
|||
toolbar.addHandler("video", (value) => { |
|||
if (value) { |
|||
let range = editor!.getSelection(true); |
|||
editor!.insertText(range.index, '\n', Quill.sources.USER); |
|||
let url = 'https://alist.xieyaxin.top/d/%E8%B5%84%E6%BA%90/%E3%80%90%E5%BB%BA%E8%AE%AE%E6%94%B6%E8%97%8F%E3%80%91IPv4%E5%88%86%E9%85%8D%E8%80%97%E5%B0%BD%EF%BC%9F%E4%BA%BA%E4%BA%BA%E9%83%BD%E6%9C%89%E7%9A%84%E5%85%AC%E7%BD%91IP%EF%BC%8CIPv6%E6%96%B0%E6%89%8B%E5%85%A5%E9%97%A8%EF%BC%8C%E7%94%B5%E8%84%91%E8%B7%AF%E7%94%B1%E5%99%A8%E9%85%8D%E7%BD%AEIPv6%E5%9C%B0%E5%9D%80%EF%BC%8CIPv6%E9%80%9A%E4%BF%A1%E6%B5%81%E7%A8%8B%EF%BC%8CIPv4%E7%9A%84NAT%E7%BD%91%E7%BB%9C%E5%9C%B0%E5%9D%80%E8%BD%AC%E6%8D%A2%E5%AD%98%E5%9C%A8%E7%9A%84%E9%97%AE%E9%A2%98%EF%BC%8CIPv6-PD%E5%89%8D%E7%BC%80%E5%A7%94%E6%89%98%E4%B8%8B%E5%8F%91%E6%97%A0%E9%99%90%E5%85%AC%E7%BD%91IPv6%E5%9C%B0%E5%9D%80.mp4?sign=zRn6CLBSrRGO6IPz7F0NPHiIeKkK7bsRNMtUrZNrN9k=:1759587506'; |
|||
editor!.insertEmbed(range.index + 1, 'video', { |
|||
url: url, |
|||
autoplay: "true", |
|||
loop: "true", |
|||
muted: "true", |
|||
width: "100%", |
|||
height: "auto", |
|||
controls: "true", |
|||
}, Quill.sources.USER); |
|||
editor!.formatText(range.index + 1, 1, { height: '170', width: '400' }); |
|||
editor!.setSelection(range.index + 2, Quill.sources.SILENT); |
|||
} else { |
|||
editor!.format("video", false); |
|||
} |
|||
}); |
|||
if (option.handleImageUpload) { |
|||
toolbar.addHandler('image', async function () { |
|||
const input = document.createElement("input"); |
|||
input.setAttribute("type", "file"); |
|||
input.setAttribute("multiple", "multiple"); |
|||
input.setAttribute("accept", "image/*"); |
|||
input.click(); |
|||
input.onchange = async () => { |
|||
const files = input!.files |
|||
const textOrArray = files ? await option.handleImageUpload?.(files) : null; |
|||
if (typeof textOrArray === "string") { |
|||
const range = editor!.getSelection(); |
|||
editor!.insertEmbed(range ? range.index : 0, 'image', textOrArray, 'user') |
|||
} else { |
|||
(textOrArray || []).forEach(text => { |
|||
const range = editor!.getSelection(); |
|||
editor!.insertEmbed(range ? range.index : 0, 'image', text, 'user') |
|||
}) |
|||
} |
|||
}; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
function destroy() { |
|||
if (editor) { |
|||
editor.off("text-change", onTextChange); |
|||
// @ts-ignore
|
|||
editor.destroy(); |
|||
editor = null; |
|||
} |
|||
} |
|||
|
|||
onMounted(init.bind(null, option)); |
|||
|
|||
onScopeDispose(destroy); |
|||
|
|||
return { |
|||
isReadyPromise, |
|||
setContent, |
|||
init, |
|||
destroy, |
|||
getEditor: () => editor, |
|||
}; |
|||
} |
|||
@ -0,0 +1,75 @@ |
|||
// @ts-nocheck
|
|||
import Quill from "quill"; |
|||
import "./quill-video" |
|||
|
|||
if (Quill.prototype.destroy === undefined) { |
|||
Quill.prototype.destroy = function () { |
|||
if (!this.emitter) return; |
|||
// Disable the editor to prevent further user input
|
|||
this.enable(false); |
|||
|
|||
// Remove event listeners managed by Quill
|
|||
this.emitter.listeners = {}; |
|||
this.emitter.off(); |
|||
|
|||
// Clear clipboard event handlers
|
|||
if (this.clipboard && this.clipboard.off) { |
|||
this.clipboard.off(); |
|||
} |
|||
|
|||
// Remove keyboard bindings
|
|||
this.keyboard.bindings = {}; |
|||
|
|||
// Clear history stack
|
|||
this.history.clear(); |
|||
|
|||
// Remove toolbar event handlers (if toolbar module exists)
|
|||
if (this.theme && this.theme.modules.toolbar) { |
|||
this.theme.modules.toolbar.container.remove(); |
|||
} |
|||
|
|||
// Remove tooltip (if present)
|
|||
if (this.theme && this.theme.tooltip) { |
|||
this.theme.tooltip.root.remove(); |
|||
} |
|||
|
|||
// Remove all Quill-added classes from the container
|
|||
const container = this.container; |
|||
container.classList.forEach((cls) => { |
|||
if (cls.startsWith('ql-')) { |
|||
container.classList.remove(cls); |
|||
} |
|||
}); |
|||
|
|||
// Restore the original container content (before Quill modified it)
|
|||
container.innerHTML = this.root.innerHTML; |
|||
|
|||
// Remove Quill-specific DOM elements
|
|||
this.root.remove(); |
|||
|
|||
// Nullify references to allow garbage collection
|
|||
this.root = null; |
|||
this.scroll = null; |
|||
this.emitter = null; |
|||
this.clipboard = null; |
|||
this.keyboard = null; |
|||
this.history = null; |
|||
this.theme = null; |
|||
this.container = null; |
|||
|
|||
// Override isEnabled to prevent errors after destruction
|
|||
this.isEnabled = function () { |
|||
return false; |
|||
}; |
|||
|
|||
// Remove the instance from Quill's internal registry (if any)
|
|||
if (Quill.instances && Quill.instances[this.id]) { |
|||
delete Quill.instances[this.id]; |
|||
} |
|||
}; |
|||
} |
|||
|
|||
export { |
|||
Quill |
|||
} |
|||
export default Quill |
|||
@ -0,0 +1,75 @@ |
|||
// @ts-nocheck
|
|||
|
|||
import Quill from "quill"; |
|||
|
|||
// 源码中是import直接倒入,这里要用Quill.import引入
|
|||
const BlockEmbed = Quill.import('blots/block/embed') |
|||
const Link = Quill.import('formats/link') |
|||
|
|||
const ATTRIBUTES = ['height', 'width'] |
|||
|
|||
class Video extends BlockEmbed { |
|||
static create(value) { |
|||
let node = super.create() |
|||
//添加
|
|||
node.setAttribute('src', value.url) |
|||
node.setAttribute('controls', value.controls) |
|||
node.setAttribute('width', value.width) |
|||
node.setAttribute('height', value.height) |
|||
node.setAttribute('loop', value.loop) |
|||
node.setAttribute('autoplay', value.autoplay) |
|||
node.setAttribute('muted', value.muted) |
|||
return node |
|||
} |
|||
|
|||
static formats(domNode) { |
|||
return ATTRIBUTES.reduce((formats, attribute) => { |
|||
if (domNode.hasAttribute(attribute)) { |
|||
formats[attribute] = domNode.getAttribute(attribute) |
|||
} |
|||
return formats |
|||
}, {}) |
|||
} |
|||
|
|||
static sanitize(url) { |
|||
return Link.sanitize(url) |
|||
} |
|||
|
|||
static value(domNode) { |
|||
// 设置值包含宽高,为了达到自定义效果
|
|||
//宽高为空的话,就是按100%算
|
|||
return { |
|||
url: domNode.getAttribute('src'), |
|||
controls: domNode.getAttribute('controls'), |
|||
width: domNode.getAttribute('width'), |
|||
height: domNode.getAttribute('height'), |
|||
autoplay: domNode.getAttribute('autoplay'), |
|||
loop: domNode.getAttribute('loop'), |
|||
muted: domNode.getAttribute('muted'), |
|||
|
|||
} |
|||
} |
|||
|
|||
|
|||
format(name, value) { |
|||
if (ATTRIBUTES.indexOf(name) > -1) { |
|||
if (value) { |
|||
this.domNode.setAttribute(name, value) |
|||
} else { |
|||
this.domNode.removeAttribute(name) |
|||
} |
|||
} else { |
|||
super.format(name, value) |
|||
} |
|||
} |
|||
|
|||
html() { |
|||
const { video } = this.value() |
|||
return `<a href="${video}">${video}</a>` |
|||
} |
|||
} |
|||
Video.blotName = 'video' |
|||
// Video.className = 'ql-video' // 可添加样式,看主要需要
|
|||
Video.tagName = 'video' // 用video标签替换iframe
|
|||
|
|||
Quill.register(Video, true); |
|||
@ -0,0 +1,111 @@ |
|||
<script setup lang="ts"> |
|||
import { useTheme } from "@maz-ui/themes"; |
|||
|
|||
const { presetName, updateTheme, colorMode, setColorMode, isDark } = useTheme(); |
|||
|
|||
const presetCookie = useCookie("maz-preset-mode"); |
|||
console.log("presetCookie", presetCookie.get()); |
|||
onServerPrefetch(() => { |
|||
const colorCookie = useCookie("maz-color-mode"); |
|||
|
|||
if (colorCookie.get()) { |
|||
setColorMode(colorCookie.get() as "light" | "dark" | "auto"); |
|||
} |
|||
if (presetCookie.get()) { |
|||
updateTheme( |
|||
presetCookie.get() as "mazUi" | "ocean" | "pristine" | "obsidian" |
|||
); |
|||
} |
|||
}); |
|||
watchEffect(() => { |
|||
if (presetCookie.get()) { |
|||
updateTheme( |
|||
presetCookie.get() as "mazUi" | "ocean" | "pristine" | "obsidian" |
|||
); |
|||
} |
|||
}); |
|||
watch( |
|||
() => presetName.value, |
|||
() => { |
|||
if (presetName.value) { |
|||
presetCookie.set(presetName.value!, { |
|||
path: "/", |
|||
maxAge: 60 * 60 * 24 * 365, |
|||
}); |
|||
} |
|||
} |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
{{ colorMode }}{{ presetName }}{{ isDark }} |
|||
<div class="demo-theme-controls"> |
|||
<div class="maz-space-y-4"> |
|||
<div class="maz-grid maz-grid-cols-1 md:maz-grid-cols-2 maz-gap-4"> |
|||
<MazBtn color="primary">Primary Button</MazBtn> |
|||
<MazBtn color="secondary">Secondary Button</MazBtn> |
|||
<MazBtn color="success">Success Button</MazBtn> |
|||
<MazBtn color="warning">Warning Button</MazBtn> |
|||
</div> |
|||
<div class="theme-controls maz-space-y-4"> |
|||
<div class="maz-flex maz-items-center maz-gap-4"> |
|||
<label class="maz-text-sm maz-font-medium">Mode:</label> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="colorMode === 'light' ? 'primary' : 'secondary'" |
|||
@click="setColorMode('light')" |
|||
> |
|||
☀️ Light |
|||
</MazBtn> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="colorMode === 'dark' ? 'primary' : 'secondary'" |
|||
@click="setColorMode('dark')" |
|||
> |
|||
🌙 Dark |
|||
</MazBtn> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="colorMode === 'auto' ? 'primary' : 'secondary'" |
|||
@click="setColorMode('auto')" |
|||
> |
|||
🔄 Auto |
|||
</MazBtn> |
|||
</div> |
|||
<div class="maz-space-y-2"> |
|||
<label class="maz-text-sm maz-font-medium">Preset:</label> |
|||
<div class="maz-flex maz-gap-2"> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="presetName === 'maz-ui' ? 'primary' : 'secondary'" |
|||
@click="updateTheme('mazUi')" |
|||
> |
|||
Maz-UI |
|||
</MazBtn> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="presetName === 'ocean' ? 'primary' : 'secondary'" |
|||
@click="updateTheme('ocean')" |
|||
> |
|||
Ocean |
|||
</MazBtn> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="presetName === 'pristine' ? 'primary' : 'secondary'" |
|||
@click="updateTheme('pristine')" |
|||
> |
|||
Pristine |
|||
</MazBtn> |
|||
<MazBtn |
|||
size="sm" |
|||
:color="presetName === 'obsidian' ? 'primary' : 'secondary'" |
|||
@click="updateTheme('obsidian')" |
|||
> |
|||
Obsidian |
|||
</MazBtn> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,28 @@ |
|||
<script setup lang="ts"> |
|||
import { ModalsContainer } from "vue-final-modal"; |
|||
|
|||
const { openCache } = useGlobal(); |
|||
const cacheList = ref<string[]>([]); |
|||
|
|||
const route = useRoute(); |
|||
|
|||
watch( |
|||
() => route.fullPath, |
|||
() => { |
|||
if (route.meta.cache && !cacheList.value.includes(route.name as string)) { |
|||
cacheList.value.push(route.name as string); |
|||
} |
|||
}, |
|||
{ immediate: true } |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
<RouterView v-slot="{ Component, route }"> |
|||
<keep-alive :include="cacheList" v-if="openCache"> |
|||
<component :key="route.fullPath" :is="Component" /> |
|||
</keep-alive> |
|||
<component v-else :key="route.fullPath" :is="Component" /> |
|||
</RouterView> |
|||
<ModalsContainer></ModalsContainer> |
|||
</template> |
|||
@ -0,0 +1,54 @@ |
|||
<script setup lang="ts"> |
|||
import { VueFinalModal } from "vue-final-modal"; |
|||
|
|||
defineProps<{ |
|||
title?: string; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
(e: "confirm"): void; |
|||
}>(); |
|||
</script> |
|||
|
|||
<template> |
|||
<VueFinalModal |
|||
class="confirm-modal" |
|||
content-class="confirm-modal-content" |
|||
overlay-transition="vfm-fade" |
|||
content-transition="vfm-fade" |
|||
> |
|||
<h1>{{ title }}</h1> |
|||
<slot /> |
|||
<MazBtn @click="emit('confirm')">Confirm</MazBtn> |
|||
</VueFinalModal> |
|||
</template> |
|||
|
|||
<style> |
|||
.confirm-modal { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
.confirm-modal-content { |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 1rem; |
|||
background: #fff; |
|||
border-radius: 0.5rem; |
|||
} |
|||
.confirm-modal-content > * + * { |
|||
margin: 0.5rem 0; |
|||
} |
|||
.confirm-modal-content h1 { |
|||
font-size: 1.375rem; |
|||
} |
|||
.confirm-modal-content button { |
|||
margin: 0.25rem 0 0 auto; |
|||
padding: 0 8px; |
|||
border: 1px solid; |
|||
border-radius: 0.5rem; |
|||
} |
|||
.dark .confirm-modal-content { |
|||
background: #000; |
|||
} |
|||
</style> |
|||
@ -1,11 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<h1 @click="$router.back()">About Page</h1> |
|||
</div> |
|||
</template> |
|||
<script setup lang="ts"> |
|||
|
|||
defineOptions({ |
|||
name: "about" |
|||
}) |
|||
</script> |
|||
@ -1,19 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
<h1>Home Page</h1> |
|||
<input type="text" /> |
|||
<router-link to="/about">前往/about</router-link> |
|||
{{ user }} |
|||
<ClientOnly> |
|||
<AiDemo></AiDemo> |
|||
</ClientOnly> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineOptions({ |
|||
name: "home", |
|||
}); |
|||
|
|||
const { user } = useAuthStore(); |
|||
</script> |
|||
@ -0,0 +1,32 @@ |
|||
<template> |
|||
<MazBtn @click="open"> Open Modal </MazBtn> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
definePage({ |
|||
name: "home", |
|||
alias: ["/", "/home"], |
|||
meta: { |
|||
cache: true, |
|||
}, |
|||
}); |
|||
defineOptions({ |
|||
name: "home", |
|||
}); |
|||
|
|||
import { useModal } from "vue-final-modal"; |
|||
import ModalConfirmPlainCss from "./_M.vue"; |
|||
|
|||
const { open, close } = useModal({ |
|||
component: ModalConfirmPlainCss, |
|||
attrs: { |
|||
title: "Hello World!", |
|||
onConfirm() { |
|||
close(); |
|||
}, |
|||
}, |
|||
slots: { |
|||
default: "<p>The content of the modal</p>", |
|||
}, |
|||
}); |
|||
</script> |
|||
@ -1,5 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
NotFound |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,34 @@ |
|||
<template> |
|||
<div @click="$router.push('/test/index2')">HOMaaE</div> |
|||
<input type="text" /> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
definePage({ |
|||
name: "test", |
|||
meta: { |
|||
cache: false, |
|||
}, |
|||
}); |
|||
defineOptions({ |
|||
name: "test", |
|||
}); |
|||
useHead({ |
|||
title: "Home Page", |
|||
meta: [ |
|||
{ |
|||
name: "description", |
|||
content: "Welcome to our website", |
|||
}, |
|||
], |
|||
}); |
|||
|
|||
if (import.meta.env.SSR) { |
|||
const cache = useShareCache()!; |
|||
cache.set("time", Date.now()); |
|||
cache.set("homeData", { message: "Hello from Home Page!" }); |
|||
console.log("Home ssrContext.cache:", cache); |
|||
} else { |
|||
console.log(useShareCache()); |
|||
} |
|||
</script> |
|||
@ -0,0 +1,28 @@ |
|||
<template> |
|||
<div> |
|||
<h1 @click="$router.back()">About Page</h1> |
|||
<MazBtn @click="visible = !visible"> Exec animation </MazBtn> |
|||
<MazInput></MazInput> |
|||
<MazExpandAnimation v-model="visible"> |
|||
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod |
|||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim |
|||
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea |
|||
commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit |
|||
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim |
|||
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip |
|||
ex ea commodo consequat. |
|||
</MazExpandAnimation> |
|||
</div> |
|||
</template> |
|||
<script setup lang="ts"> |
|||
definePage({ |
|||
name: "about", |
|||
meta: { |
|||
cache: true |
|||
} |
|||
}) |
|||
defineOptions({ |
|||
name: "about", |
|||
}); |
|||
const visible = ref(false); |
|||
</script> |
|||
@ -0,0 +1 @@ |
|||
仅供测试的界面 |
|||
@ -1,16 +1,28 @@ |
|||
// src/router.js
|
|||
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'; |
|||
import NotFound from '../pages/not-found/index.vue'; |
|||
// https://uvr.esm.is/guide/extending-routes.html#definepage
|
|||
|
|||
import { createRouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router'; |
|||
// import NotFound from '../pages/not-found/index.vue';
|
|||
import { routes } from 'vue-router/auto-routes' |
|||
import { setupLayouts } from 'virtual:generated-layouts' |
|||
|
|||
// import BaseLayout from '@/layouts/base.vue';
|
|||
|
|||
export default function createSSRRouter() { |
|||
return createRouter({ |
|||
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式
|
|||
routes: [ |
|||
{ name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, |
|||
{ name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, |
|||
routes: setupLayouts(routes), |
|||
// routes: [
|
|||
// {
|
|||
// name: "BaseLayout", path: '', component: BaseLayout, children: [
|
|||
// { name: "home", path: '', meta: { cache: true }, component: () => import('../pages/home/index.vue') },
|
|||
// { name: "about", path: 'about', meta: { cache: true }, component: () => import('../pages/about/index.vue') },
|
|||
// ]
|
|||
// },
|
|||
// // { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') },
|
|||
// // { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') },
|
|||
|
|||
// 404
|
|||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, |
|||
], |
|||
// // 404
|
|||
// { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
|
|||
// ],
|
|||
}); |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
/* eslint-disable */ |
|||
/* prettier-ignore */ |
|||
// @ts-nocheck
|
|||
// Generated by unplugin-vue-router. \u203C\uFE0F DO NOT MODIFY THIS FILE \u203C\uFE0F
|
|||
// It's recommended to commit this file.
|
|||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
|||
|
|||
declare module 'vue-router/auto-routes' { |
|||
import type { |
|||
RouteRecordInfo, |
|||
ParamValue, |
|||
ParamValueOneOrMore, |
|||
ParamValueZeroOrMore, |
|||
ParamValueZeroOrOne, |
|||
} from 'vue-router' |
|||
|
|||
/** |
|||
* Route name map generated by unplugin-vue-router |
|||
*/ |
|||
export interface RouteNamedMap { |
|||
'home': RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>, |
|||
'/_M': RouteRecordInfo<'/_M', '/_M', Record<never, never>, Record<never, never>>, |
|||
'test': RouteRecordInfo<'test', '/test', Record<never, never>, Record<never, never>>, |
|||
'about': RouteRecordInfo<'about', '/test/index2', Record<never, never>, Record<never, never>>, |
|||
} |
|||
|
|||
/** |
|||
* Route file to route info map by unplugin-vue-router. |
|||
* Used by the volar plugin to automatically type useRoute() |
|||
* |
|||
* Each key is a file path relative to the project root with 2 properties: |
|||
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>()) |
|||
* - views: names of nested views (can be passed to <RouterView name="...">) |
|||
* |
|||
* @internal |
|||
*/ |
|||
export interface _RouteFileInfoMap { |
|||
'src/pages/index.vue': { |
|||
routes: 'home' |
|||
views: never |
|||
} |
|||
'src/pages/_M.vue': { |
|||
routes: '/_M' |
|||
views: never |
|||
} |
|||
'src/pages/test/index.vue': { |
|||
routes: 'test' |
|||
views: never |
|||
} |
|||
'src/pages/test/index2.vue': { |
|||
routes: 'about' |
|||
views: never |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get a union of possible route names in a certain route component file. |
|||
* Used by the volar plugin to automatically type useRoute() |
|||
* |
|||
* @internal |
|||
*/ |
|||
export type _RouteNamesForFilePath<FilePath extends string> = |
|||
_RouteFileInfoMap extends Record<FilePath, infer Info> |
|||
? Info['routes'] |
|||
: keyof RouteNamedMap |
|||
} |
|||
@ -1,10 +1,20 @@ |
|||
import 'vue-router' |
|||
|
|||
// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明
|
|||
export { } |
|||
|
|||
declare module 'vue' { |
|||
export interface ComponentCustomProperties { |
|||
declare module 'vue-router' { |
|||
interface RouteMeta { |
|||
// 是可选的
|
|||
cache?: boolean |
|||
} |
|||
} |
|||
|
|||
declare module '@vue/runtime-core' { |
|||
interface ComponentCustomProperties { |
|||
$ssrContext?: Record<string, any> |
|||
} |
|||
export interface ComponentInternalInstance { |
|||
interface ComponentInternalInstance { |
|||
_nuxtClientOnly?: boolean |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,318 @@ |
|||
// @ts-nocheck
|
|||
import { R } from "@/utils/R" |
|||
import { logger } from "@/logger.js" |
|||
import CommonError from "@/utils/error/CommonError.js" |
|||
|
|||
/** |
|||
* 基础控制器类 |
|||
* 提供通用的错误处理、响应格式化等功能 |
|||
* 所有控制器都应继承此类 |
|||
*/ |
|||
class BaseController { |
|||
constructor() { |
|||
// 绑定所有方法的this上下文,确保在路由中使用时this指向正确
|
|||
this._bindMethods() |
|||
} |
|||
|
|||
/** |
|||
* 绑定所有方法的this上下文 |
|||
* @private |
|||
*/ |
|||
_bindMethods() { |
|||
const proto = Object.getPrototypeOf(this) |
|||
const propertyNames = Object.getOwnPropertyNames(proto) |
|||
|
|||
propertyNames.forEach(name => { |
|||
|
|||
if (name !== 'constructor' && typeof this[name] === 'function') { |
|||
this[name] = this[name].bind(this) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 统一成功响应 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {*} data - 响应数据 |
|||
* @param {string} message - 响应消息 |
|||
* @param {number} statusCode - HTTP状态码 |
|||
*/ |
|||
success(ctx, data = null, message = null, statusCode = 200) { |
|||
return R.response(statusCode, data, message) |
|||
} |
|||
|
|||
/** |
|||
* 统一错误响应 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {string} message - 错误消息 |
|||
* @param {*} data - 错误数据 |
|||
* @param {number} statusCode - HTTP状态码 |
|||
*/ |
|||
error(ctx, message = "操作失败", data = null, statusCode = 500) { |
|||
return R.response(statusCode, data, message) |
|||
} |
|||
|
|||
/** |
|||
* 统一异常处理装饰器 |
|||
* 用于包装控制器方法,自动处理异常 |
|||
* @param {Function} handler - 控制器方法 |
|||
* @returns {Function} 包装后的方法 |
|||
*/ |
|||
handleRequest(handler) { |
|||
return async (ctx, next) => { |
|||
try { |
|||
await handler.call(this, ctx, next) |
|||
} catch (error) { |
|||
logger.error("Controller error:", error) |
|||
|
|||
if (error instanceof CommonError) { |
|||
// 业务异常,返回具体错误信息
|
|||
return this.error(ctx, error.message, null, 400) |
|||
} |
|||
|
|||
// 系统异常,返回通用错误信息
|
|||
return this.error(ctx, "系统内部错误", null, 500) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 分页响应助手 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {Object} paginationResult - 分页结果 |
|||
* @param {string} message - 响应消息 |
|||
*/ |
|||
paginated(ctx, paginationResult, message = "获取数据成功") { |
|||
const { data, pagination } = paginationResult |
|||
return this.success(ctx, { |
|||
list: data, |
|||
pagination |
|||
}, message) |
|||
} |
|||
|
|||
/** |
|||
* 验证请求参数 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {Object} rules - 验证规则 |
|||
* @throws {CommonError} 验证失败时抛出异常 |
|||
*/ |
|||
validateParams(ctx, rules) { |
|||
const data = { ...ctx.request.body, ...ctx.query, ...ctx.params } |
|||
|
|||
for (const [field, rule] of Object.entries(rules)) { |
|||
const value = data[field] |
|||
|
|||
// 必填验证
|
|||
if (rule.required && (value === undefined || value === null || value === '')) { |
|||
throw new CommonError(`${rule.label || field}不能为空`) |
|||
} |
|||
|
|||
// 类型验证
|
|||
if (value !== undefined && value !== null && rule.type) { |
|||
if (rule.type === 'number' && isNaN(value)) { |
|||
throw new CommonError(`${rule.label || field}必须是数字`) |
|||
} |
|||
if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { |
|||
throw new CommonError(`${rule.label || field}格式不正确`) |
|||
} |
|||
} |
|||
|
|||
// 长度验证
|
|||
if (value && rule.minLength && value.length < rule.minLength) { |
|||
throw new CommonError(`${rule.label || field}长度不能少于${rule.minLength}个字符`) |
|||
} |
|||
if (value && rule.maxLength && value.length > rule.maxLength) { |
|||
throw new CommonError(`${rule.label || field}长度不能超过${rule.maxLength}个字符`) |
|||
} |
|||
} |
|||
|
|||
return data |
|||
} |
|||
|
|||
/** |
|||
* 获取分页参数 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {Object} defaults - 默认值 |
|||
* @returns {Object} 分页参数 |
|||
*/ |
|||
getPaginationParams(ctx, defaults = {}) { |
|||
const { |
|||
page = defaults.page || 1, |
|||
limit = defaults.limit || 10, |
|||
orderBy = defaults.orderBy || 'created_at', |
|||
order = defaults.order || 'desc' |
|||
} = ctx.query |
|||
|
|||
return { |
|||
page: Math.max(1, parseInt(page) || 1), |
|||
limit: Math.min(100, Math.max(1, parseInt(limit) || 10)), // 限制最大100条
|
|||
orderBy, |
|||
order: order.toLowerCase() === 'asc' ? 'asc' : 'desc' |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取搜索参数 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @returns {Object} 搜索参数 |
|||
*/ |
|||
getSearchParams(ctx) { |
|||
const { keyword, status, category, author } = ctx.query |
|||
|
|||
const params = {} |
|||
if (keyword && keyword.trim()) { |
|||
params.keyword = keyword.trim() |
|||
} |
|||
if (status) { |
|||
params.status = status |
|||
} |
|||
if (category) { |
|||
params.category = category |
|||
} |
|||
if (author) { |
|||
params.author = author |
|||
} |
|||
|
|||
return params |
|||
} |
|||
|
|||
/** |
|||
* 处理文件上传 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {string} fieldName - 文件字段名 |
|||
* @returns {Object} 文件信息 |
|||
*/ |
|||
getUploadedFile(ctx, fieldName = 'file') { |
|||
const files = ctx.request.files |
|||
if (!files || !files[fieldName]) { |
|||
return null |
|||
} |
|||
|
|||
const file = Array.isArray(files[fieldName]) ? files[fieldName][0] : files[fieldName] |
|||
return { |
|||
name: file.originalFilename || file.name, |
|||
size: file.size, |
|||
type: file.mimetype || file.type, |
|||
path: file.filepath || file.path |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 重定向助手 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {string} url - 重定向URL |
|||
* @param {string} message - 提示消息 |
|||
*/ |
|||
redirect(ctx, url, message = null) { |
|||
if (message) { |
|||
// 设置flash消息(如果有toast中间件)
|
|||
if (ctx.flash) { |
|||
ctx.flash('success', message) |
|||
} |
|||
} |
|||
ctx.redirect(url) |
|||
} |
|||
|
|||
/** |
|||
* 渲染视图助手 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {string} template - 模板路径 |
|||
* @param {Object} data - 模板数据 |
|||
* @param {Object} options - 渲染选项 |
|||
*/ |
|||
async render(ctx, template, data = {}, options = {}) { |
|||
const defaultOptions = { |
|||
// includeSite: true,
|
|||
// includeUser: true,
|
|||
...options |
|||
} |
|||
|
|||
return await ctx.render(template, data, defaultOptions) |
|||
} |
|||
|
|||
/** |
|||
* JSON API响应助手 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {*} data - 响应数据 |
|||
* @param {string} message - 响应消息 |
|||
* @param {number} statusCode - HTTP状态码 |
|||
*/ |
|||
json(ctx, data = null, message = null, statusCode = 200) { |
|||
ctx.status = statusCode |
|||
ctx.body = { |
|||
success: statusCode < 400, |
|||
data, |
|||
message, |
|||
timestamp: new Date().toISOString() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取当前用户 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @returns {Object|null} 用户信息 |
|||
*/ |
|||
getCurrentUser(ctx) { |
|||
return ctx.state.user || null |
|||
} |
|||
|
|||
/** |
|||
* 检查用户是否已登录 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @returns {boolean} 是否已登录 |
|||
*/ |
|||
isLoggedIn(ctx) { |
|||
return !!ctx.state.user |
|||
} |
|||
|
|||
/** |
|||
* 获取用户ID |
|||
* @param {*} ctx - Koa上下文 |
|||
* @returns {string|number|null} 用户ID |
|||
*/ |
|||
getCurrentUserId(ctx) { |
|||
const user = this.getCurrentUser(ctx) |
|||
return user ? (user.id || user._id || null) : null |
|||
} |
|||
|
|||
/** |
|||
* 检查用户权限 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {string|Array} permission - 权限名或权限数组 |
|||
* @throws {CommonError} 权限不足时抛出异常 |
|||
*/ |
|||
checkPermission(ctx, permission) { |
|||
const user = this.getCurrentUser(ctx) |
|||
if (!user) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
// 这里可以根据实际需求实现权限检查逻辑
|
|||
// 例如检查用户角色、权限列表等
|
|||
// if (!user.hasPermission(permission)) {
|
|||
// throw new CommonError("权限不足")
|
|||
// }
|
|||
} |
|||
|
|||
/** |
|||
* 检查资源所有权 |
|||
* @param {*} ctx - Koa上下文 |
|||
* @param {Object} resource - 资源对象 |
|||
* @param {string} ownerField - 所有者字段名,默认为'author' |
|||
* @throws {CommonError} 无权限时抛出异常 |
|||
*/ |
|||
checkOwnership(ctx, resource, ownerField = 'author') { |
|||
const user = this.getCurrentUser(ctx) |
|||
if (!user) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const userId = this.getCurrentUserId(ctx) |
|||
if (resource[ownerField] !== userId && resource[ownerField] !== user.username) { |
|||
throw new CommonError("无权限操作此资源") |
|||
} |
|||
} |
|||
} |
|||
|
|||
export default BaseController |
|||
export { BaseController } |
|||
@ -1,20 +1,38 @@ |
|||
import app from "./app" |
|||
import { bootstrapServer } from "./api/main" |
|||
import { SsrMiddleWare } from "core/SsrMiddleWare" |
|||
import LoadMiddleware from "./middleware/install" |
|||
import { Env } from "helper/env" |
|||
import os from "node:os" |
|||
|
|||
import "./jobs" |
|||
|
|||
bootstrapServer() |
|||
|
|||
SsrMiddleWare(app, { |
|||
onDevViteClose() { |
|||
console.log("Vite dev server closed") |
|||
if (server) { |
|||
server.close() |
|||
console.log('Server closed') |
|||
} |
|||
} |
|||
}) |
|||
await LoadMiddleware(app) |
|||
|
|||
const server = app.listen(Env.port, () => { |
|||
console.log(`Server started at http://localhost:${Env.port}`) |
|||
const address = server.address() |
|||
if (address != null && typeof address !== 'string') { |
|||
const port = address.port |
|||
// 获取本地 IP
|
|||
const getLocalIP = () => { |
|||
const interfaces = os.networkInterfaces() |
|||
for (const name of Object.keys(interfaces)) { |
|||
if (!interfaces[name]) continue |
|||
for (const iface of interfaces[name]) { |
|||
if (iface.family === "IPv4" && !iface.internal) { |
|||
return iface.address |
|||
} |
|||
} |
|||
} |
|||
return "localhost" |
|||
} |
|||
const localIP = getLocalIP() |
|||
console.log(`────────────────────────────────────────`) |
|||
console.log(`🚀 服务器已启动`) |
|||
console.log(` 本地访问: http://localhost:${port}`) |
|||
console.log(` 局域网: http://${localIP}:${port}`) |
|||
console.log(` 启动时间: ${new Date().toLocaleString()}`) |
|||
console.log(`────────────────────────────────────────`) |
|||
} |
|||
}) |
|||
|
|||
@ -0,0 +1,11 @@ |
|||
|
|||
declare global { |
|||
namespace NodeJS { |
|||
interface ProcessEnv { |
|||
SESSION_SECRET: string; |
|||
JWT_SECRET: string; |
|||
} |
|||
} |
|||
} |
|||
|
|||
export { }; |
|||
@ -0,0 +1,62 @@ |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
import scheduler from './scheduler'; |
|||
import { TaskOptions } from 'node-cron'; |
|||
|
|||
interface OneJob { |
|||
id: string |
|||
cronTime: string |
|||
task: Function |
|||
options: TaskOptions, |
|||
autoStart: boolean, |
|||
[key: string]: Function | string | boolean | TaskOptions | undefined |
|||
} |
|||
|
|||
export function defineJob(job: OneJob) { |
|||
return job; |
|||
} |
|||
|
|||
const jobsDir = path.join(__dirname, 'jobs'); |
|||
const jobModules: Record<string, OneJob> = {}; |
|||
|
|||
fs.readdirSync(jobsDir).forEach(file => { |
|||
if (!file.endsWith('Job.ts')) 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: string, hookName: string) { |
|||
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: string) => { |
|||
callHook(id, 'beforeStart'); |
|||
scheduler.start(id); |
|||
}, |
|||
stop: (id: string) => { |
|||
scheduler.stop(id); |
|||
callHook(id, 'afterStop'); |
|||
}, |
|||
updateCronTime: (id: string, cronTime: string) => scheduler.updateCronTime(id, cronTime), |
|||
list: () => scheduler.list(), |
|||
reload: (id: string) => { |
|||
const job = jobModules[id]; |
|||
if (job) { |
|||
scheduler.remove(id); |
|||
scheduler.add(job.id, job.cronTime, job.task, job.options); |
|||
} |
|||
} |
|||
}; |
|||
@ -0,0 +1,12 @@ |
|||
import { jobLogger } from "@/logger" |
|||
import { defineJob } from ".." |
|||
|
|||
export default defineJob({ |
|||
id: "example", |
|||
cronTime: "*/10 * * * * *", // 每10秒执行一次
|
|||
task: () => { |
|||
jobLogger.info("Example Job 执行了") |
|||
}, |
|||
options: {}, |
|||
autoStart: false, |
|||
}) |
|||
@ -0,0 +1,63 @@ |
|||
import cron, { ScheduledTask, TaskFn, TaskOptions } from 'node-cron'; |
|||
|
|||
export interface Job { job: ScheduledTask; cronTime: string; task: Function; options: TaskOptions; status: 'running' | 'stopped' } |
|||
|
|||
class Scheduler { |
|||
jobs: Map<string, Job>; |
|||
constructor() { |
|||
this.jobs = new Map(); |
|||
} |
|||
|
|||
add(id: string, cronTime: string, task: Function, options: TaskOptions = {}) { |
|||
if (this.jobs.has(id)) this.remove(id); |
|||
const job = cron.createTask(cronTime, task as TaskFn, { ...options, noOverlap: true }); |
|||
this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); |
|||
} |
|||
|
|||
execute(id: string) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.execute(); |
|||
} |
|||
} |
|||
|
|||
start(id: string) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status !== 'running') { |
|||
entry.job.start(); |
|||
entry.status = 'running'; |
|||
} |
|||
} |
|||
|
|||
stop(id: string) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.stop(); |
|||
entry.status = 'stopped'; |
|||
} |
|||
} |
|||
|
|||
remove(id: string) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry) { |
|||
entry.job.destroy(); |
|||
this.jobs.delete(id); |
|||
} |
|||
} |
|||
|
|||
updateCronTime(id: string, newCronTime: string) { |
|||
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,61 @@ |
|||
|
|||
import log4js from "log4js"; |
|||
import { Env } from 'helper/env'; |
|||
|
|||
const { LOG_DIR } = Env; |
|||
|
|||
log4js.configure({ |
|||
appenders: { |
|||
all: { |
|||
type: "file", |
|||
filename: `${LOG_DIR}/all.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', |
|||
}, |
|||
}, |
|||
error: { |
|||
type: "file", |
|||
filename: `${LOG_DIR}/error.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', |
|||
}, |
|||
}, |
|||
jobs: { |
|||
type: "file", |
|||
filename: `${LOG_DIR}/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', |
|||
}, |
|||
}, |
|||
console: { |
|||
type: "console", |
|||
layout: { |
|||
type: "pattern", |
|||
pattern: '\x1b[90m[%d{hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m', |
|||
}, |
|||
}, |
|||
}, |
|||
categories: { |
|||
jobs: { appenders: ["console", "jobs"], level: "info" }, |
|||
// error: { appenders: ["console", "error"], level: "error" },
|
|||
default: { appenders: ["console", "all"], level: "all" }, |
|||
}, |
|||
}); |
|||
|
|||
export const logger = log4js.getLogger(); |
|||
export const jobLogger = log4js.getLogger('jobs'); |
|||
@ -0,0 +1,38 @@ |
|||
import { minimatch } from "minimatch" |
|||
import CommonError from "@/utils/error/CommonError" |
|||
import { DefaultContext, Next } from "koa" |
|||
|
|||
export const JWT_SECRET = process.env.JWT_SECRET |
|||
|
|||
function matchList(list: any[], path: string) { |
|||
for (const item of list) { |
|||
if (typeof item === "string" && minimatch(path, item, { dot: true })) { |
|||
return { matched: true } |
|||
} |
|||
if (typeof item === "object" && minimatch(path, item.pattern, { dot: true })) { |
|||
return { matched: true } |
|||
} |
|||
} |
|||
return { matched: false } |
|||
} |
|||
|
|||
export function AuthMiddleware(options: any = { |
|||
whiteList: [], |
|||
blackList: [] |
|||
}) { |
|||
return (ctx: DefaultContext, next: Next) => { |
|||
if (ctx.session.user) { |
|||
ctx.state.user = ctx.session.user |
|||
} |
|||
// 黑名单优先生效
|
|||
if (matchList(options.blackList, ctx.path).matched) { |
|||
throw new CommonError("禁止访问", CommonError.ERR_CODE.FORBIDDEN) |
|||
} |
|||
// 白名单处理
|
|||
const white = matchList(options.whiteList, ctx.path) |
|||
if (!white.matched) { |
|||
throw new CommonError(`禁止访问:${ctx.path}`, CommonError.ERR_CODE.FORBIDDEN) |
|||
} |
|||
return next() |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
import fs from "fs" |
|||
import path from "path" |
|||
import { logger } from "@/logger.js" |
|||
import compose from "koa-compose" |
|||
import { Next, ParameterizedContext } from "koa" |
|||
|
|||
async function scanControllers(rootDir: string) { |
|||
const routers = [] |
|||
const stack: string[] = [rootDir] |
|||
while (stack.length) { |
|||
const dir = stack.pop() |
|||
if (!dir) continue |
|||
let files |
|||
try { |
|||
files = fs.readdirSync(dir) |
|||
} catch (error: any) { |
|||
logger.error(`[控制器注册] ❌ 读取目录失败 ${dir}: ${error.message}`) |
|||
continue |
|||
} |
|||
|
|||
for (const file of files) { |
|||
if (file.startsWith("_")) continue |
|||
const fullPath = path.join(dir, file) |
|||
let stat |
|||
try { |
|||
stat = fs.statSync(fullPath) |
|||
} catch (error: any) { |
|||
logger.error(`[控制器注册] ❌ 读取文件信息失败 ${fullPath}: ${error.message}`) |
|||
continue |
|||
} |
|||
|
|||
if (stat.isDirectory()) { |
|||
stack.push(fullPath) |
|||
continue |
|||
} |
|||
|
|||
if (!fullPath.replace(/\\/g, "/").includes("/controller/")) continue |
|||
|
|||
let fileName = fullPath.replace(rootDir + path.sep, "") |
|||
|
|||
try { |
|||
const controllerModule = await import(fullPath) |
|||
const controller = controllerModule.default || controllerModule |
|||
if (!controller) { |
|||
logger.warn(`[控制器注册] ${fileName} - 缺少默认导出,跳过注册`) |
|||
continue |
|||
} |
|||
|
|||
let routesFactory = controller.createRoutes || controller.default?.createRoutes || controller.default || controller |
|||
if (typeof routesFactory === "function") { |
|||
routesFactory = routesFactory.bind(controller) |
|||
} |
|||
if (typeof routesFactory !== "function") { |
|||
logger.warn(`[控制器注册] ⚠️ ${fileName} - 未找到 createRoutes 方法或导出对象`) |
|||
continue |
|||
} |
|||
|
|||
let routerResult |
|||
try { |
|||
routerResult = routesFactory() |
|||
} catch (error: any) { |
|||
logger.error(`[控制器注册] ❌ ${fileName} - createRoutes() 执行失败: ${error.message}`) |
|||
continue |
|||
} |
|||
|
|||
const list = Array.isArray(routerResult) ? routerResult : [routerResult] |
|||
let added = 0 |
|||
for (const r of list) { |
|||
if (r && typeof r.middleware === "function") { |
|||
routers.push(r) |
|||
added++ |
|||
} else { |
|||
logger.warn(`[控制器注册] ⚠️ ${fileName} - createRoutes() 返回的部分路由器对象无效`) |
|||
} |
|||
} |
|||
if (added > 0) logger.debug(`[控制器注册] ✅ ${fileName} - 创建成功 (${added})`) |
|||
} catch (importError: any) { |
|||
logger.error(`[控制器注册] ❌ ${fileName} - 模块导入失败: ${importError.message}`) |
|||
logger.error(importError) |
|||
} |
|||
} |
|||
} |
|||
return routers |
|||
} |
|||
|
|||
export default async function (options: { root: string, handleBeforeEachRequest: Function }) { |
|||
const { root, handleBeforeEachRequest } = options |
|||
if (!root) { |
|||
throw new Error("controller root is required") |
|||
} |
|||
const routers = await scanControllers(root) |
|||
const allRouters: any[] = [] |
|||
for (let i = 0; i < routers.length; i++) { |
|||
const router = routers[i] |
|||
allRouters.push(router.middleware((options = {}) => handleBeforeEachRequest(options))) |
|||
} |
|||
return async function (ctx: ParameterizedContext, next: Next) { |
|||
return await compose(allRouters)(ctx, next) |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
import { logger } from "@/logger" |
|||
import type { ParameterizedContext, Next } from "koa" |
|||
|
|||
// 静态资源扩展名列表
|
|||
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] |
|||
|
|||
function isStaticResource(path: string): boolean { |
|||
return staticExts.some(ext => path.endsWith(ext)) |
|||
} |
|||
|
|||
export default async (ctx: ParameterizedContext, next: 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) { |
|||
logger.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,186 @@ |
|||
// @ts-nocheck
|
|||
/** |
|||
* 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: string, relativePath: string) { |
|||
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,16 @@ |
|||
import { DefaultContext } from 'koa'; |
|||
import session from 'koa-session'; |
|||
|
|||
export default (app: DefaultContext) => { |
|||
const CONFIG = { |
|||
key: 'koa:sess', // cookie key
|
|||
maxAge: 86400000, // 1天
|
|||
httpOnly: true, |
|||
signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys
|
|||
rolling: false, |
|||
renew: false, |
|||
secure: process.env.NODE_ENV === "production" && process.env.HTTPS_ENABLE === "on", |
|||
sameSite: "strict", // https://scotthelme.co.uk/csrf-is-dead/
|
|||
}; |
|||
return session(CONFIG, app); |
|||
}; |
|||
@ -0,0 +1,104 @@ |
|||
|
|||
import { SsrMiddleWare } from "core/SsrMiddleWare" |
|||
import bodyParser from "koa-bodyparser" |
|||
import app from "@/app" |
|||
import ResponseTime from "./ResponseTime" |
|||
import Controller from "./Controller" |
|||
import path from "node:path" |
|||
import jwt from "jsonwebtoken" |
|||
import AuthError from "@/utils/error/AuthError" |
|||
import CommonError from "@/utils/error/CommonError" |
|||
import { DefaultContext, Next, ParameterizedContext } from "koa" |
|||
import { AuthMiddleware } from "./Auth" |
|||
import Session from "./Session" |
|||
import Send from "./Send" |
|||
import { getPathByRoot } from "helper/path" |
|||
|
|||
type App = typeof app |
|||
|
|||
export default async (app: App) => { |
|||
|
|||
app.use(ResponseTime) |
|||
|
|||
// 拦截 Chrome DevTools 探测请求,直接返回 204
|
|||
app.use((ctx: DefaultContext, next: Next) => { |
|||
if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { |
|||
ctx.status = 204 |
|||
ctx.body = "" |
|||
return |
|||
} |
|||
return next() |
|||
}) |
|||
|
|||
|
|||
const publicPath = getPathByRoot("public") |
|||
app.use(async (ctx, next) => { |
|||
if (!ctx.path.startsWith("/public")) return await next() |
|||
if (ctx.method.toLowerCase() === "get") { |
|||
try { |
|||
await Send(ctx, ctx.path.replace("/public", ""), { root: publicPath, maxAge: 0, immutable: false }) |
|||
} catch (err: any) { |
|||
if (err.status !== 404) throw err |
|||
} |
|||
} |
|||
}) |
|||
|
|||
app.use(Session(app)) |
|||
|
|||
// 权限设置
|
|||
app.use( |
|||
AuthMiddleware({ |
|||
whiteList: [ |
|||
// 所有请求放行
|
|||
{ pattern: "/" }, |
|||
{ pattern: "/**/*" }, |
|||
], |
|||
blackList: [ |
|||
// 禁用api请求
|
|||
// "/api",
|
|||
// "/api/",
|
|||
// "/api/**/*",
|
|||
], |
|||
}) |
|||
) |
|||
|
|||
app.use(bodyParser()) |
|||
app.use( |
|||
await Controller({ |
|||
root: path.resolve(__dirname, "../modules"), |
|||
handleBeforeEachRequest: (options: any) => { |
|||
const { auth = true } = options || {} |
|||
return async (ctx: ParameterizedContext, next: Next) => { |
|||
if (ctx.session && ctx.session.user) { |
|||
ctx.state.user = ctx.session.user |
|||
} else { |
|||
const authorizationString = ctx.headers && ctx.headers["authorization"] |
|||
if (authorizationString) { |
|||
const token = authorizationString.replace(/^Bearer\s/, "") |
|||
try { |
|||
ctx.state.user = jwt.verify(token, process.env.JWT_SECRET) |
|||
} catch (_) { |
|||
// 无效token忽略
|
|||
} |
|||
} |
|||
} |
|||
|
|||
if (auth === false && ctx.state.user) { |
|||
throw new CommonError("不能登录查看") |
|||
} |
|||
if (auth === "try") { |
|||
return next() |
|||
} |
|||
if (auth === true && !ctx.state.user) { |
|||
throw new AuthError("需要登录才能访问") |
|||
} |
|||
|
|||
return await next() |
|||
} |
|||
}, |
|||
}) |
|||
) |
|||
// 处理SSR的插件,理应放在所有路由中间件的最后
|
|||
await SsrMiddleWare(app) |
|||
|
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
|
|||
import JobService from "../services" |
|||
import { R } from "@/utils/R" |
|||
import Router from "@/utils/Router" |
|||
import { ParameterizedContext } from "koa" |
|||
|
|||
class JobController { |
|||
|
|||
static createRoutes() { |
|||
const controller = new this() |
|||
const router = new Router({ prefix: "/api/jobs", auth: "try" }) |
|||
router.get("", controller.list.bind(controller)) |
|||
router.get("/", controller.list.bind(controller)) |
|||
router.get("/start/:id", controller.start.bind(controller)) |
|||
router.post("/stop/:id", controller.stop.bind(controller)) |
|||
router.post("/update/:id", controller.updateCron.bind(controller)) |
|||
return router |
|||
} |
|||
|
|||
jobService: JobService |
|||
|
|||
constructor() { |
|||
this.jobService = new JobService() |
|||
} |
|||
|
|||
async list(ctx: ParameterizedContext) { |
|||
const data = this.jobService.listJobs() |
|||
R.response(R.SUCCESS, data) |
|||
} |
|||
|
|||
async start(ctx: ParameterizedContext) { |
|||
const { id } = ctx.params |
|||
this.jobService.startJob(id) |
|||
R.response(R.SUCCESS, null, `${id} 任务已启动`) |
|||
} |
|||
|
|||
async stop(ctx: ParameterizedContext) { |
|||
const { id } = ctx.params |
|||
this.jobService.stopJob(id) |
|||
R.response(R.SUCCESS, null, `${id} 任务已停止`) |
|||
} |
|||
|
|||
async updateCron(ctx: ParameterizedContext) { |
|||
const { id } = ctx.params |
|||
const { cronTime } = ctx.request.body as { cronTime: string } |
|||
this.jobService.updateJobCron(id, cronTime) |
|||
R.response(R.SUCCESS, null, `${id} 任务频率已修改`) |
|||
} |
|||
} |
|||
|
|||
export default JobController |
|||
@ -0,0 +1,18 @@ |
|||
import jobs from "@/jobs" |
|||
|
|||
class JobService { |
|||
startJob(id: string) { |
|||
return jobs.start(id) |
|||
} |
|||
stopJob(id: string) { |
|||
return jobs.stop(id) |
|||
} |
|||
updateJobCron(id: string, cronTime: string) { |
|||
return jobs.updateCronTime(id, cronTime) |
|||
} |
|||
listJobs() { |
|||
return jobs.list() |
|||
} |
|||
} |
|||
|
|||
export default JobService |
|||
@ -0,0 +1,207 @@ |
|||
// @ts-nocheck
|
|||
import Router from "@/utils/Router" |
|||
import formidable from "formidable" |
|||
import fs from "fs/promises" |
|||
import path from "path" |
|||
import { fileURLToPath } from "url" |
|||
import { logger } from "@/logger.js" |
|||
import { R } from "@/utils/R" |
|||
import BaseController from "@/base/BaseController" |
|||
import { getPathByRoot } from "helper/path" |
|||
|
|||
/** |
|||
* 文件上传控制器 |
|||
* 负责处理通用文件上传功能 |
|||
*/ |
|||
class UploadController extends BaseController { |
|||
|
|||
/** |
|||
* 创建文件上传相关路由 |
|||
* @returns {Router} 路由实例 |
|||
*/ |
|||
static createRoutes() { |
|||
const controller = new this() |
|||
const router = new Router({ auth: "try" }) |
|||
|
|||
// 通用文件上传
|
|||
router.post("/upload", controller.handleRequest(controller.upload), { auth: "try" }) |
|||
|
|||
return router |
|||
} |
|||
|
|||
constructor() { |
|||
super() |
|||
// 初始化上传配置
|
|||
this.initConfig() |
|||
} |
|||
|
|||
/** |
|||
* 初始化上传配置 |
|||
*/ |
|||
initConfig() { |
|||
// 默认支持的文件类型配置
|
|||
this.defaultTypeList = [ |
|||
{ mime: "image/jpeg", ext: ".jpg" }, |
|||
{ mime: "image/png", ext: ".png" }, |
|||
{ mime: "image/webp", ext: ".webp" }, |
|||
{ mime: "image/gif", ext: ".gif" }, |
|||
{ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx
|
|||
{ mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls
|
|||
{ mime: "application/msword", ext: ".doc" }, // .doc
|
|||
{ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx
|
|||
] |
|||
|
|||
this.fallbackExt = ".bin" |
|||
this.maxFileSize = 10 * 1024 * 1024 // 10MB
|
|||
} |
|||
|
|||
/** |
|||
* 获取允许的文件类型 |
|||
* @param {Object} ctx - Koa上下文 |
|||
* @returns {Array} 允许的文件类型列表 |
|||
*/ |
|||
getAllowedTypes(ctx) { |
|||
let typeList = this.defaultTypeList |
|||
|
|||
// 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型)
|
|||
if (ctx.query.allowedTypes) { |
|||
const allowed = ctx.query.allowedTypes |
|||
.split(",") |
|||
.map(t => t.trim()) |
|||
.filter(Boolean) |
|||
typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime)) |
|||
} |
|||
|
|||
return typeList |
|||
} |
|||
|
|||
/** |
|||
* 获取上传目录路径 |
|||
* @returns {string} 上传目录路径 |
|||
*/ |
|||
getUploadDir() { |
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
|||
const publicDir = getPathByRoot("public") |
|||
return path.resolve(publicDir, "uploads/files") |
|||
} |
|||
|
|||
/** |
|||
* 确保上传目录存在 |
|||
* @param {string} dir - 目录路径 |
|||
*/ |
|||
async ensureUploadDir(dir) { |
|||
await fs.mkdir(dir, { recursive: true }) |
|||
} |
|||
|
|||
/** |
|||
* 生成安全的文件名 |
|||
* @param {Object} ctx - Koa上下文 |
|||
* @param {string} ext - 文件扩展名 |
|||
* @returns {string} 生成的文件名 |
|||
*/ |
|||
generateFileName(ctx, ext) { |
|||
// return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`
|
|||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` |
|||
} |
|||
|
|||
/** |
|||
* 获取文件扩展名 |
|||
* @param {Object} file - 文件对象 |
|||
* @param {Array} typeList - 类型列表 |
|||
* @returns {string} 文件扩展名 |
|||
*/ |
|||
getFileExtension(file, typeList) { |
|||
// 优先用mimetype判断扩展名
|
|||
let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext |
|||
if (!ext) { |
|||
// 回退到原始文件名的扩展名
|
|||
ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt |
|||
} |
|||
return ext |
|||
} |
|||
|
|||
/** |
|||
* 处理单个文件上传 |
|||
* @param {Object} file - 文件对象 |
|||
* @param {Object} ctx - Koa上下文 |
|||
* @param {string} uploadsDir - 上传目录 |
|||
* @param {Array} typeList - 类型列表 |
|||
* @returns {string} 文件URL |
|||
*/ |
|||
async processFile(file, ctx, uploadsDir, typeList) { |
|||
if (!file) return null |
|||
|
|||
const oldPath = file.filepath || file.path |
|||
const ext = this.getFileExtension(file, typeList) |
|||
const filename = this.generateFileName(ctx, ext) |
|||
const destPath = path.join(uploadsDir, filename) |
|||
|
|||
// 移动文件到目标位置
|
|||
if (oldPath && oldPath !== destPath) { |
|||
await fs.rename(oldPath, destPath) |
|||
} |
|||
|
|||
// 返回相对于public的URL路径
|
|||
return `/public/uploads/files/${filename}` |
|||
} |
|||
|
|||
// 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组)
|
|||
async upload(ctx) { |
|||
try { |
|||
const uploadsDir = this.getUploadDir() |
|||
await this.ensureUploadDir(uploadsDir) |
|||
|
|||
const typeList = this.getAllowedTypes(ctx) |
|||
const allowedTypes = typeList.map(item => item.mime) |
|||
|
|||
const form = formidable({ |
|||
multiples: true, // 支持多文件
|
|||
maxFileSize: this.maxFileSize, |
|||
filter: ({ mimetype }) => { |
|||
return !!mimetype && allowedTypes.includes(mimetype) |
|||
}, |
|||
uploadDir: uploadsDir, |
|||
keepExtensions: true, |
|||
}) |
|||
|
|||
const { files } = await new Promise((resolve, reject) => { |
|||
form.parse(ctx.req, (err, fields, files) => { |
|||
if (err) return reject(err) |
|||
resolve({ fields, files }) |
|||
}) |
|||
}) |
|||
|
|||
let fileList = files.file |
|||
if (!fileList) { |
|||
return R.response(R.ERROR, null, "未选择文件或字段名应为 file") |
|||
} |
|||
|
|||
// 统一为数组
|
|||
if (!Array.isArray(fileList)) { |
|||
fileList = [fileList] |
|||
} |
|||
|
|||
// 处理所有文件
|
|||
const urls = [] |
|||
for (const file of fileList) { |
|||
const url = await this.processFile(file, ctx, uploadsDir, typeList) |
|||
if (url) { |
|||
urls.push(url) |
|||
} |
|||
} |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "上传成功", |
|||
urls, |
|||
} |
|||
} catch (error) { |
|||
logger.error(`上传失败: ${error.message}`) |
|||
ctx.status = 500 |
|||
ctx.body = { success: false, message: error.message || "上传失败" } |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
export default UploadController |
|||
@ -0,0 +1,165 @@ |
|||
import { logger } from "@/logger.js" |
|||
|
|||
/** |
|||
* 环境变量验证配置 |
|||
* required: 必需的环境变量 |
|||
* optional: 可选的环境变量(提供默认值) |
|||
*/ |
|||
const ENV_CONFIG = { |
|||
required: [ |
|||
"SESSION_SECRET", |
|||
"JWT_SECRET" |
|||
], |
|||
optional: { |
|||
"NODE_ENV": "development", |
|||
"PORT": "3000", |
|||
"LOG_DIR": "logs", |
|||
"HTTPS_ENABLE": "off" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 验证必需的环境变量 |
|||
* @returns {Object} 验证结果 |
|||
*/ |
|||
function validateRequiredEnv() { |
|||
const missing = [] |
|||
const valid: Record<string, string> = {} |
|||
|
|||
for (const key of ENV_CONFIG.required) { |
|||
const value = process.env[key] |
|||
if (!value || value.trim() === '') { |
|||
missing.push(key) |
|||
} else { |
|||
valid[key] = value |
|||
} |
|||
} |
|||
|
|||
return { missing, valid } |
|||
} |
|||
|
|||
/** |
|||
* 设置可选环境变量的默认值 |
|||
* @returns {Object} 设置的默认值 |
|||
*/ |
|||
function setOptionalDefaults() { |
|||
const defaults: Record<string, string> = {} |
|||
|
|||
for (const [key, defaultValue] of Object.entries(ENV_CONFIG.optional)) { |
|||
if (!process.env[key]) { |
|||
process.env[key] = defaultValue |
|||
defaults[key] = defaultValue |
|||
} |
|||
} |
|||
|
|||
return defaults |
|||
} |
|||
|
|||
/** |
|||
* 验证环境变量的格式和有效性 |
|||
* @param {Object} env 环境变量对象 |
|||
* @returns {Array} 错误列表 |
|||
*/ |
|||
function validateEnvFormat(env: NodeJS.ProcessEnv) { |
|||
const errors = [] |
|||
|
|||
// 验证 PORT 是数字
|
|||
if (env.PORT && isNaN(parseInt(env.PORT))) { |
|||
errors.push("PORT must be a valid number") |
|||
} |
|||
|
|||
// 验证 NODE_ENV 的值
|
|||
const validNodeEnvs = ['development', 'production', 'test'] |
|||
if (env.NODE_ENV && !validNodeEnvs.includes(env.NODE_ENV)) { |
|||
errors.push(`NODE_ENV must be one of: ${validNodeEnvs.join(', ')}`) |
|||
} |
|||
|
|||
// 验证 SESSION_SECRET 至少包含一个密钥
|
|||
if (env.SESSION_SECRET) { |
|||
const secrets = env.SESSION_SECRET.split(',').filter(s => s.trim()) |
|||
if (secrets.length === 0) { |
|||
errors.push("SESSION_SECRET must contain at least one non-empty secret") |
|||
} |
|||
} |
|||
|
|||
// 验证 JWT_SECRET 长度
|
|||
if (env.JWT_SECRET && env.JWT_SECRET.length < 32) { |
|||
errors.push("JWT_SECRET must be at least 32 characters long for security") |
|||
} |
|||
|
|||
return errors |
|||
} |
|||
|
|||
/** |
|||
* 初始化和验证所有环境变量 |
|||
* @returns {boolean} 验证是否成功 |
|||
*/ |
|||
export function validateEnvironment() { |
|||
logger.info("🔍 开始验证环境变量...") |
|||
|
|||
// 1. 验证必需的环境变量
|
|||
const { missing, valid } = validateRequiredEnv() |
|||
|
|||
if (missing.length > 0) { |
|||
logger.error("❌ 缺少必需的环境变量:") |
|||
missing.forEach(key => { |
|||
logger.error(` - ${key}`) |
|||
}) |
|||
logger.error("请设置这些环境变量后重新启动应用") |
|||
return false |
|||
} |
|||
|
|||
// 2. 设置可选环境变量的默认值
|
|||
const defaults = setOptionalDefaults() |
|||
if (Object.keys(defaults).length > 0) { |
|||
logger.info("⚙️ 设置默认环境变量:") |
|||
Object.entries(defaults).forEach(([key, value]) => { |
|||
logger.info(` - ${key}=${value}`) |
|||
}) |
|||
} |
|||
|
|||
// 3. 验证环境变量格式
|
|||
const formatErrors = validateEnvFormat(process.env) |
|||
if (formatErrors.length > 0) { |
|||
logger.error("❌ 环境变量格式错误:") |
|||
formatErrors.forEach(error => { |
|||
logger.error(` - ${error}`) |
|||
}) |
|||
return false |
|||
} |
|||
|
|||
// 4. 记录有效的环境变量(敏感信息脱敏)
|
|||
logger.info("✅ 环境变量验证成功:") |
|||
logger.info(` - NODE_ENV=${process.env.NODE_ENV}`) |
|||
logger.info(` - PORT=${process.env.PORT}`) |
|||
logger.info(` - LOG_DIR=${process.env.LOG_DIR}`) |
|||
logger.info(` - SESSION_SECRET=${maskSecret(process.env.SESSION_SECRET)}`) |
|||
logger.info(` - JWT_SECRET=${maskSecret(process.env.JWT_SECRET)}`) |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 脱敏显示敏感信息 |
|||
* @param {string} secret 敏感字符串 |
|||
* @returns {string} 脱敏后的字符串 |
|||
*/ |
|||
export function maskSecret(secret: string) { |
|||
if (!secret) return "未设置" |
|||
if (secret.length <= 8) return "*".repeat(secret.length) |
|||
return secret.substring(0, 4) + "*".repeat(secret.length - 8) + secret.substring(secret.length - 4) |
|||
} |
|||
|
|||
/** |
|||
* 获取环境变量配置(用于生成 .env.example) |
|||
* @returns {Object} 环境变量配置 |
|||
*/ |
|||
export function getEnvConfig() { |
|||
return ENV_CONFIG |
|||
} |
|||
|
|||
export default { |
|||
validateEnvironment, |
|||
getEnvConfig, |
|||
maskSecret |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { app } from "@/app" |
|||
|
|||
function success(data = null, message = null) { |
|||
const ctx = app.currentContext! |
|||
ctx.status = 200 |
|||
ctx.set("Content-Type", "application/json") |
|||
return ctx.body = { success: true, error: message, data } |
|||
} |
|||
|
|||
function error(data = null, message = null) { |
|||
const ctx = app.currentContext! |
|||
ctx.status = 500 |
|||
ctx.set("Content-Type", "application/json") |
|||
return ctx.body = { success: false, error: message, data } |
|||
} |
|||
|
|||
function response<T>(statusCode: number, data: T | null = null, message: string | null = null) { |
|||
const ctx = app.currentContext! |
|||
ctx.status = statusCode |
|||
ctx.set("Content-Type", "application/json") |
|||
return ctx.body = { success: true, error: message, data } |
|||
} |
|||
|
|||
const R = { |
|||
success, |
|||
error, |
|||
response, |
|||
|
|||
SUCCESS: 200, |
|||
ERROR: 500, |
|||
NOTFOUND: 404, |
|||
} |
|||
|
|||
export { R } |
|||
export default R |
|||
@ -0,0 +1,141 @@ |
|||
import { match } from 'path-to-regexp'; |
|||
import compose from 'koa-compose'; |
|||
import { Next, ParameterizedContext } from 'koa'; |
|||
|
|||
class Router { |
|||
|
|||
routes: any |
|||
middlewares: any |
|||
|
|||
/** |
|||
* 初始化路由实例 |
|||
* @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: Function) { |
|||
this.middlewares.push(middleware); |
|||
} |
|||
|
|||
/** |
|||
* 注册GET路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {Function} handler - 中间件和处理函数 |
|||
* @param {Object} others - 其他参数(可选) |
|||
*/ |
|||
get(path: string, handler: Function, others: Object = {}) { |
|||
this._registerRoute("get", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册POST路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {Function} handler - 中间件和处理函数 |
|||
* @param {Object} others - 其他参数(可选) |
|||
*/ |
|||
post(path: string, handler: Function, others: Object = {}) { |
|||
this._registerRoute("post", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册PUT路由,支持中间件链 |
|||
*/ |
|||
put(path: string, handler: Function, others: Object = {}) { |
|||
this._registerRoute("put", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册DELETE路由,支持中间件链 |
|||
*/ |
|||
delete(path: string, handler: Function, others: Object = {}) { |
|||
this._registerRoute("delete", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 创建路由组 |
|||
* @param {string} prefix - 组内路由前缀 |
|||
* @param {Function} callback - 组路由注册回调 |
|||
*/ |
|||
group(prefix: string, callback: Function) { |
|||
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(beforeMiddleware?: Function) { |
|||
return async (ctx: ParameterizedContext, next: Next) => { |
|||
const { method, path } = ctx; |
|||
// 直接进行路由匹配(不使用缓存)
|
|||
const route = this._matchRoute(method.toLowerCase(), path); |
|||
|
|||
// 组合全局中间件、路由专属中间件和 handler
|
|||
const middlewares = [...this.middlewares]; |
|||
if (route) { |
|||
// 如果匹配到路由,添加路由专属中间件和处理函数
|
|||
ctx.params = route.params; |
|||
|
|||
if (beforeMiddleware) { |
|||
const options = Object.assign({}, this.options, route.meta); |
|||
middlewares.push(await beforeMiddleware(options)); |
|||
} |
|||
middlewares.push(route.handler); |
|||
const composed = compose(middlewares); |
|||
await composed(ctx, next); |
|||
} else { |
|||
// 如果没有匹配到路由,直接调用 next
|
|||
await next(); |
|||
} |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 内部路由注册方法,支持中间件链 |
|||
* @private |
|||
*/ |
|||
_registerRoute(method: string, path: string, handler: Function, others: Object) { |
|||
const fullPath = this.options.prefix + path |
|||
const keys: string[] = []; |
|||
const matcher = match(fullPath, { decode: decodeURIComponent }); |
|||
this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others }) |
|||
} |
|||
|
|||
/** |
|||
* 匹配路由 |
|||
* @private |
|||
*/ |
|||
_matchRoute(method: string, currentPath: string) { |
|||
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,24 @@ |
|||
import app from "@/app" |
|||
import BaseError from "./BaseError" |
|||
import { DefaultContext } from "koa" |
|||
|
|||
export default class ApiError extends BaseError { |
|||
ctx?: DefaultContext |
|||
user?: any = null |
|||
info?: null | any = null |
|||
|
|||
constructor(message: string, status = ApiError.ERR_CODE.BAD_REQUEST) { |
|||
super(message, status) |
|||
this.name = "ApiError" |
|||
const ctx = app.currentContext |
|||
this.ctx = ctx |
|||
this.user = ctx?.state?.user || null |
|||
this.info = { |
|||
path: ctx?.path || "", |
|||
method: ctx?.method || "", |
|||
query: ctx?.query || {}, |
|||
body: ctx?.request?.body || {}, |
|||
params: ctx?.params || {}, |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import app from "@/app" |
|||
import BaseError from "./BaseError" |
|||
import { DefaultContext } from "koa" |
|||
|
|||
export default class AuthError extends BaseError { |
|||
ctx?: DefaultContext |
|||
|
|||
constructor(message: string, status = AuthError.ERR_CODE.UNAUTHORIZED) { |
|||
super(message, status) |
|||
this.name = "AuthError" |
|||
this.ctx = app.currentContext |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
|
|||
export class BaseError extends Error { |
|||
statusCode: number |
|||
|
|||
static ERR_CODE = { |
|||
NOT_FOUND: 404, |
|||
UNAUTHORIZED: 401, |
|||
FORBIDDEN: 403, |
|||
BAD_REQUEST: 400, |
|||
INTERNAL_SERVER_ERROR: 500, |
|||
} |
|||
constructor(message: string, code: number) { |
|||
super(message) |
|||
this.statusCode = code |
|||
} |
|||
} |
|||
export default BaseError |
|||
@ -0,0 +1,24 @@ |
|||
import app from "@/app" |
|||
import BaseError from "./BaseError.js" |
|||
import { DefaultContext } from "koa" |
|||
|
|||
export default class CommonError extends BaseError { |
|||
ctx?: DefaultContext |
|||
user?: any = null |
|||
info?: null | any = null |
|||
|
|||
constructor(message: string, status = CommonError.ERR_CODE.BAD_REQUEST) { |
|||
super(message, status) |
|||
this.name = "CommonError" |
|||
const ctx = app.currentContext |
|||
this.ctx = ctx |
|||
this.user = ctx?.state?.user || null |
|||
this.info = { |
|||
path: ctx?.path || "", |
|||
method: ctx?.method || "", |
|||
query: ctx?.query || {}, |
|||
body: ctx?.request?.body || {}, |
|||
params: ctx?.params || {}, |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
import fs from "fs"; |
|||
import path from "path"; |
|||
|
|||
const dtsPath = path.resolve( |
|||
import.meta.dirname, |
|||
"../node_modules/vue-router/dist/vue-router.d.ts" |
|||
); |
|||
|
|||
const content = fs.readFileSync(dtsPath, "utf8"); |
|||
|
|||
const fixedContent = content.replace( |
|||
/declare module ['"]vue['"]/g, |
|||
"declare module '@vue/runtime-core'" |
|||
); |
|||
|
|||
fs.writeFileSync(dtsPath, fixedContent, "utf8"); |
|||
|
|||
console.log("Fixed vue-router.d.ts module declaration."); |
|||
Loading…
Reference in new issue