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 |
# 基于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> |
<!DOCTYPE html> |
||||
<html lang="en"> |
<html> |
||||
|
<head> |
||||
<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> |
|
||||
<!--app-head--> |
<!--app-head--> |
||||
</head> |
</head> |
||||
|
<body> |
||||
<body> |
|
||||
<div id="app"><!--app-html--></div> |
<div id="app"><!--app-html--></div> |
||||
<script type="module" src="./src/entry-client.ts"></script> |
<script type="module" src="/src/entry-client.ts"></script> |
||||
</body> |
</body> |
||||
|
|
||||
</html> |
</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
|
// https://uvr.esm.is/guide/extending-routes.html#definepage
|
||||
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'; |
|
||||
import NotFound from '../pages/not-found/index.vue'; |
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() { |
export default function createSSRRouter() { |
||||
return createRouter({ |
return createRouter({ |
||||
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式
|
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式
|
||||
routes: [ |
routes: setupLayouts(routes), |
||||
{ name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, |
// routes: [
|
||||
{ name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, |
// {
|
||||
|
// 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
|
// // 404
|
||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, |
// { 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 { } |
export { } |
||||
|
|
||||
declare module 'vue' { |
declare module 'vue-router' { |
||||
export interface ComponentCustomProperties { |
interface RouteMeta { |
||||
|
// 是可选的
|
||||
|
cache?: boolean |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
declare module '@vue/runtime-core' { |
||||
|
interface ComponentCustomProperties { |
||||
$ssrContext?: Record<string, any> |
$ssrContext?: Record<string, any> |
||||
} |
} |
||||
export interface ComponentInternalInstance { |
interface ComponentInternalInstance { |
||||
_nuxtClientOnly?: boolean |
_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 app from "./app" |
||||
import { bootstrapServer } from "./api/main" |
import { bootstrapServer } from "./api/main" |
||||
import { SsrMiddleWare } from "core/SsrMiddleWare" |
import LoadMiddleware from "./middleware/install" |
||||
import { Env } from "helper/env" |
import { Env } from "helper/env" |
||||
|
import os from "node:os" |
||||
|
|
||||
|
import "./jobs" |
||||
|
|
||||
bootstrapServer() |
bootstrapServer() |
||||
|
|
||||
SsrMiddleWare(app, { |
await LoadMiddleware(app) |
||||
onDevViteClose() { |
|
||||
console.log("Vite dev server closed") |
|
||||
if (server) { |
|
||||
server.close() |
|
||||
console.log('Server closed') |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
const server = app.listen(Env.port, () => { |
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