Browse Source

feat: Add middleware for response time logging and static file serving

- 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
dash 2 months ago
parent
commit
1cd96abed6
  1. 5
      README.md
  2. BIN
      bun.lockb
  3. 2
      internal/helper/src/env.ts
  4. 10
      internal/x/composables/useShareContext.ts
  5. 7
      package.json
  6. 16
      packages/client/auto-imports.d.ts
  7. 5
      packages/client/components.d.ts
  8. 19
      packages/client/index.html
  9. 15
      packages/client/package.json
  10. 23
      packages/client/src/App.vue
  11. 48
      packages/client/src/assets/styles/css/reset.css
  12. 73
      packages/client/src/components/QuillEditor/_Editor.vue
  13. 10
      packages/client/src/components/QuillEditor/index.vue
  14. 135
      packages/client/src/components/QuillEditor/useQuill/index.ts
  15. 75
      packages/client/src/components/QuillEditor/useQuill/quill-shim.ts
  16. 75
      packages/client/src/components/QuillEditor/useQuill/quill-video.ts
  17. 111
      packages/client/src/components/ThemeDemo.vue
  18. 31
      packages/client/src/entry-client.ts
  19. 47
      packages/client/src/entry-server.ts
  20. 28
      packages/client/src/layouts/base.vue
  21. 6
      packages/client/src/main.ts
  22. 54
      packages/client/src/pages/_M.vue
  23. 11
      packages/client/src/pages/about/index.vue
  24. 19
      packages/client/src/pages/home/index.vue
  25. 32
      packages/client/src/pages/index.vue
  26. 5
      packages/client/src/pages/not-found/index.vue
  27. 34
      packages/client/src/pages/test/index.vue
  28. 28
      packages/client/src/pages/test/index2.vue
  29. 1
      packages/client/src/pages/test/readme.md
  30. 30
      packages/client/src/router/index.ts
  31. 66
      packages/client/src/typed-router.d.ts
  32. 2
      packages/client/src/vite-env.d.ts
  33. 16
      packages/client/src/vue.d.ts
  34. 32
      packages/client/tsconfig.json
  35. 42
      packages/client/vite.config.ts
  36. 14
      packages/core/src/SsrMiddleWare.ts
  37. 17
      packages/server/package.json
  38. 2
      packages/server/src/api/main.ts
  39. 14
      packages/server/src/app.ts
  40. 318
      packages/server/src/base/BaseController.ts
  41. 40
      packages/server/src/booststap.ts
  42. 11
      packages/server/src/env.d.ts
  43. 62
      packages/server/src/jobs/index.ts
  44. 12
      packages/server/src/jobs/jobs/exampleJob.ts
  45. 63
      packages/server/src/jobs/scheduler.ts
  46. 61
      packages/server/src/logger.ts
  47. 38
      packages/server/src/middleware/Auth/index.ts
  48. 100
      packages/server/src/middleware/Controller/index.ts
  49. 59
      packages/server/src/middleware/ResponseTime/index.ts
  50. 186
      packages/server/src/middleware/Send/index.ts
  51. 74
      packages/server/src/middleware/Send/resolve-path.ts
  52. 16
      packages/server/src/middleware/Session/index.ts
  53. 104
      packages/server/src/middleware/install.ts
  54. 51
      packages/server/src/modules/Job/controller/index.ts
  55. 18
      packages/server/src/modules/Job/services/index.ts
  56. 207
      packages/server/src/modules/Upload/controller/index.ts
  57. 165
      packages/server/src/utils/EnvValidator.ts
  58. 35
      packages/server/src/utils/R.ts
  59. 141
      packages/server/src/utils/Router.ts
  60. 24
      packages/server/src/utils/error/ApiError.ts
  61. 13
      packages/server/src/utils/error/AuthError.ts
  62. 17
      packages/server/src/utils/error/BaseError.ts
  63. 24
      packages/server/src/utils/error/CommonError.ts
  64. 7
      packages/server/tsconfig.json
  65. 18
      scripts/fix-type-router.js

5
README.md

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

BIN
bun.lockb

Binary file not shown.

2
internal/helper/src/env.ts

@ -2,10 +2,12 @@
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 5173 const port = process.env.PORT || 5173
const base = process.env.BASE || '/' const base = process.env.BASE || '/'
const LOG_DIR = process.env.LOG_DIR || 'logs'
export const Env = { export const Env = {
isProduction, isProduction,
port: Number(port), port: Number(port),
base, base,
LOG_DIR,
} }

10
internal/x/composables/useShareContext.ts

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

7
package.json

@ -6,7 +6,9 @@
"internal/*" "internal/*"
], ],
"scripts": { "scripts": {
"postinstall": "node scripts/fix-type-router.js",
"dev": "bun run --hot packages/server/src/booststap.ts", "dev": "bun run --hot packages/server/src/booststap.ts",
"build": "bun run --filter client build",
"preview": "cross-env NODE_ENV=production bun run packages/server/src/booststap.ts", "preview": "cross-env NODE_ENV=production bun run packages/server/src/booststap.ts",
"tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false", "tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false",
"tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false" "tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false"
@ -20,10 +22,11 @@
"helper": "workspace:*", "helper": "workspace:*",
"server": "workspace:*", "server": "workspace:*",
"unplugin-vue-components": "^29.1.0", "unplugin-vue-components": "^29.1.0",
"vite-plugin-devtools-json": "^1.0.0" "vite-plugin-devtools-json": "^1.0.0",
"x": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"koa-compose": "^4.1.0", "koa-compose": "^4.1.0",

16
packages/client/auto-imports.d.ts

@ -25,6 +25,7 @@ declare global {
const h: typeof import('vue')['h'] const h: typeof import('vue')['h']
const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext'] const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']
const inject: typeof import('vue')['inject'] const inject: typeof import('vue')['inject']
const injectHead: typeof import('@unhead/vue')['injectHead']
const isProxy: typeof import('vue')['isProxy'] const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive'] const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly'] const isReadonly: typeof import('vue')['isReadonly']
@ -82,11 +83,18 @@ declare global {
const useCssVars: typeof import('vue')['useCssVars'] const useCssVars: typeof import('vue')['useCssVars']
const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch'] const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch']
const useGlobal: typeof import('./src/composables/useGlobal/index')['useGlobal'] const useGlobal: typeof import('./src/composables/useGlobal/index')['useGlobal']
const useHead: typeof import('@unhead/vue')['useHead']
const useHeadSafe: typeof import('@unhead/vue')['useHeadSafe']
const useId: typeof import('vue')['useId'] const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink'] const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel'] const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute'] const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter'] const useRouter: typeof import('vue-router')['useRouter']
const useSeoMeta: typeof import('@unhead/vue')['useSeoMeta']
const useServerHead: typeof import('@unhead/vue')['useServerHead']
const useServerHeadSafe: typeof import('@unhead/vue')['useServerHeadSafe']
const useServerSeoMeta: typeof import('@unhead/vue')['useServerSeoMeta']
const useShareCache: typeof import('../../internal/x/composables/useShareContext')['useShareCache']
const useSlots: typeof import('vue')['useSlots'] const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef'] const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch'] const watch: typeof import('vue')['watch']
@ -131,6 +139,7 @@ declare module 'vue' {
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hydrateSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']> readonly hydrateSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectHead: UnwrapRef<typeof import('@unhead/vue')['injectHead']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']> readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']> readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
@ -188,11 +197,18 @@ declare module 'vue' {
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']> readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useFetch: UnwrapRef<typeof import('../../internal/x/composables/useFetch')['useFetch']> readonly useFetch: UnwrapRef<typeof import('../../internal/x/composables/useFetch')['useFetch']>
readonly useGlobal: UnwrapRef<typeof import('./src/composables/useGlobal/index')['useGlobal']> readonly useGlobal: UnwrapRef<typeof import('./src/composables/useGlobal/index')['useGlobal']>
readonly useHead: UnwrapRef<typeof import('@unhead/vue')['useHead']>
readonly useHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useHeadSafe']>
readonly useId: UnwrapRef<typeof import('vue')['useId']> readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']> readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']> readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']> readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']> readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useSeoMeta']>
readonly useServerHead: UnwrapRef<typeof import('@unhead/vue')['useServerHead']>
readonly useServerHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useServerHeadSafe']>
readonly useServerSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useServerSeoMeta']>
readonly useShareCache: UnwrapRef<typeof import('../../internal/x/composables/useShareContext')['useShareCache']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']> readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']> readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly watch: UnwrapRef<typeof import('vue')['watch']> readonly watch: UnwrapRef<typeof import('vue')['watch']>

5
packages/client/components.d.ts

@ -9,12 +9,17 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AiDemo: typeof import('./src/components/AiDemo/index.vue')['default'] AiDemo: typeof import('./src/components/AiDemo/index.vue')['default']
AXBubble: typeof import('ant-design-x-vue')['Bubble']
ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default'] ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default']
CookieDemo: typeof import('./src/components/CookieDemo.vue')['default'] CookieDemo: typeof import('./src/components/CookieDemo.vue')['default']
DataFetch: typeof import('./src/components/DataFetch.vue')['default'] DataFetch: typeof import('./src/components/DataFetch.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
MazBtn: typeof import('maz-ui/components/MazBtn')['default']
QuillEditor: typeof import('./src/components/QuillEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SimpleTest: typeof import('./src/components/SimpleTest.vue')['default'] SimpleTest: typeof import('./src/components/SimpleTest.vue')['default']
ThemeDemo: typeof import('./src/components/ThemeDemo.vue')['default']
VueNodeRenderer: typeof import('./src/components/AiDemo/_/VueNodeRenderer.vue')['default']
} }
} }

19
packages/client/index.html

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

15
packages/client/package.json

@ -11,17 +11,22 @@
"unplugin-vue-components": "^29.1.0", "unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0" "vue-tsc": "^3.1.0"
}, },
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": { "dependencies": {
"@maz-ui/icons": "^4.1.3",
"@maz-ui/themes": "^4.1.5",
"@unhead/vue": "^2.0.17",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"ant-design-x-vue": "^1.3.2",
"dompurify": "^3.2.7", "dompurify": "^3.2.7",
"htmlparser2": "^10.0.0", "htmlparser2": "^10.0.0",
"marked": "^16.3.0", "marked": "^16.3.0",
"maz-ui": "^4.1.6",
"quill": "^2.0.3",
"unplugin-auto-import": "^20.2.0", "unplugin-auto-import": "^20.2.0",
"unplugin-vue-router": "^0.15.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.5.1", "vue-final-modal": "^4.5.5",
"x": "workspace:*" "vue-router": "^4.5.1"
} }
} }

23
packages/client/src/App.vue

@ -1,20 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// import ClientOnly from "x/components/ClientOnly.vue";
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 }
);
onServerPrefetch(() => { onServerPrefetch(() => {
const AuthStore = useAuthStore(); const AuthStore = useAuthStore();
AuthStore.setUser({ AuthStore.setUser({
@ -24,12 +8,7 @@ onServerPrefetch(() => {
</script> </script>
<template> <template>
<RouterView v-slot="{ Component, route }"> <RouterView></RouterView>
<keep-alive :include="cacheList" v-if="openCache">
<component :key="route.fullPath" :is="Component" />
</keep-alive>
<component v-else :key="route.fullPath" :is="Component" />
</RouterView>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

48
packages/client/src/assets/styles/css/reset.css

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

73
packages/client/src/components/QuillEditor/_Editor.vue

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

10
packages/client/src/components/QuillEditor/index.vue

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

135
packages/client/src/components/QuillEditor/useQuill/index.ts

@ -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,
};
}

75
packages/client/src/components/QuillEditor/useQuill/quill-shim.ts

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

75
packages/client/src/components/QuillEditor/useQuill/quill-video.ts

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

111
packages/client/src/components/ThemeDemo.vue

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

31
packages/client/src/entry-client.ts

@ -1,5 +1,13 @@
import { createApp } from "./main" import { createApp } from "./main"
import { hydrateSSRContext, clearSSRContext } from 'x/composables/ssrContext' import { hydrateSSRContext, clearSSRContext } from 'x/composables/ssrContext'
import { createHead } from '@unhead/vue/client'
import "@/assets/styles/css/reset.css"
import 'vue-final-modal/style.css'
import { MazUi } from 'maz-ui/plugins/maz-ui'
import { mazUi, ocean, pristine, obsidian } from '@maz-ui/themes'
import { zhCN } from '@maz-ui/translations'
// 水合 SSR 上下文(如果存在) // 水合 SSR 上下文(如果存在)
let ssrContext = null let ssrContext = null
@ -14,6 +22,27 @@ if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) {
// 使用相同的 SSR 上下文创建应用 // 使用相同的 SSR 上下文创建应用
const { app, pinia, router } = createApp(ssrContext) const { app, pinia, router } = createApp(ssrContext)
const head = createHead()
app.use(head)
const presetCookie = useCookie("maz-preset-mode");
const colorCookie = useCookie("maz-color-mode");
app.use(MazUi, {
theme: {
mode: 'both',
strategy: 'hybrid',
// class会触发https://github.com/LouisMazel/maz-ui/blob/3051819550985506413a8f0d103e8f11b4cb17d7/packages/themes/src/composables/useTheme.ts#L165
// 使用class会触发如上链接的问题,导致执行两次setColorMode,从而覆盖掉cookie的值
darkModeStrategy: 'class', // 'class',
preset: { "maz-ui": mazUi, "ocean": ocean, "pristine": pristine, "obsidian": obsidian }[presetCookie.get() || "maz-ui"],
colorMode: presetCookie.get() ? (colorCookie.get() as "light" | "dark" | "auto") : "auto",
},
translations: {
messages: { zhCN },
},
})
if (ssrContext) { if (ssrContext) {
pinia.state.value = ssrContext.piniaState pinia.state.value = ssrContext.piniaState
} }
@ -21,7 +50,7 @@ if (ssrContext) {
// 等待路由准备就绪,然后挂载应用 // 等待路由准备就绪,然后挂载应用
router.isReady().then(() => { router.isReady().then(() => {
console.log('[Client] 路由已准备就绪,挂载应用') console.log('[Client] 路由已准备就绪,挂载应用')
app.mount('#app') app.mount('#app', true)
// 水合完成后清除 SSR 上下文 // 水合完成后清除 SSR 上下文
clearSSRContext() clearSSRContext()

47
packages/client/src/entry-server.ts

@ -2,7 +2,7 @@ import { renderToString } from 'vue/server-renderer'
import { createApp } from './main' import { createApp } from './main'
import { createSSRContext } from 'x/composables/ssrContext' import { createSSRContext } from 'x/composables/ssrContext'
import { basename } from 'node:path' import { basename } from 'node:path'
import { createHead } from '@unhead/vue/server'
export async function render(url: string, manifest: any, init?: { cookies?: Record<string, string> }) { export async function render(url: string, manifest: any, init?: { cookies?: Record<string, string> }) {
// 创建 SSR 上下文,包含数据缓存与 cookies // 创建 SSR 上下文,包含数据缓存与 cookies
@ -14,6 +14,46 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco
// 将 SSR 上下文传递给应用创建函数 // 将 SSR 上下文传递给应用创建函数
const { app, pinia, router } = createApp(ssrContext) const { app, pinia, router } = createApp(ssrContext)
const unHead = createHead({
disableDefaults: true
})
app.use(unHead)
// https://github.com/antfu-collective/vitesse
// https://github.com/unjs/unhead/blob/main/examples/vite-ssr-vue/src/entry-server.ts
useSeoMeta({
title: 'My Awesome Site',
description: 'My awesome site description',
}, { head: unHead })
useHead({
title: "aa",
htmlAttrs: {
lang: "zh-CN"
},
meta: [
{
charset: "UTF-8"
},
{
name: "viewport",
content: "width=device-width, initial-scale=1.0",
},
{
name: "description",
content: "Welcome to our website",
},
],
link: [
{
rel: "icon",
type: "image/svg+xml",
// href: () => (preferredDark.value ? "/favicon-dark.svg" : "/favicon.svg"),
href: () => "/vite.svg",
},
],
}, { head: unHead })
router.push(url); // 根据请求 URL 设置路由 router.push(url); // 根据请求 URL 设置路由
await router.isReady(); // 等待路由准备完成 await router.isReady(); // 等待路由准备完成
@ -28,24 +68,23 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco
// 使用更安全的方式序列化 Map // 使用更安全的方式序列化 Map
const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : [] const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : []
const ssrData = JSON.stringify(cacheEntries) const ssrData = JSON.stringify(cacheEntries)
const cookieInit = JSON.stringify(ssrContext.cookies || {})
// @ts-ignore // @ts-ignore
const preloadLinks = renderPreloadLinks(ctx.modules, manifest) const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
console.log('[SSR] 序列化缓存数据:', cacheEntries) console.log('[SSR] 序列化缓存数据:', cacheEntries)
const head = ` const head = `
<script> <script>
window.__SSR_CONTEXT__ = { window.__SSR_CONTEXT__ = {
cache: new Map(${ssrData}), cache: new Map(${ssrData}),
cookies: ${cookieInit},
piniaState: ${JSON.stringify(pinia.state.value || {})} piniaState: ${JSON.stringify(pinia.state.value || {})}
}; };
</script> </script>
${preloadLinks} ${preloadLinks}
` `
return { html, head, setCookies: ssrContext.setCookies || [] } return { html, head, unHead, setCookies: ssrContext.setCookies || [] }
} }
function renderPreloadLinks(modules: any, manifest: any) { function renderPreloadLinks(modules: any, manifest: any) {

28
packages/client/src/layouts/base.vue

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

6
packages/client/src/main.ts

@ -2,17 +2,17 @@ import { createSSRApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import createSSRRouter from './router'; import createSSRRouter from './router';
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createVfm } from 'vue-final-modal'
// SSR requires a fresh app instance per request, therefore we export a function
// that creates a fresh app instance. If using Vuex, we'd also be creating a
// fresh store here.
export function createApp(ssrContext?: any) { export function createApp(ssrContext?: any) {
const app = createSSRApp(App) const app = createSSRApp(App)
const router = createSSRRouter() const router = createSSRRouter()
const pinia = createPinia() const pinia = createPinia()
const vfm = createVfm() as any
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)
app.use(vfm)
// 如果有 SSR 上下文,注入到应用中 // 如果有 SSR 上下文,注入到应用中
if (ssrContext) { if (ssrContext) {

54
packages/client/src/pages/_M.vue

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

11
packages/client/src/pages/about/index.vue

@ -1,11 +0,0 @@
<template>
<div>
<h1 @click="$router.back()">About Page</h1>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "about"
})
</script>

19
packages/client/src/pages/home/index.vue

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

32
packages/client/src/pages/index.vue

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

5
packages/client/src/pages/not-found/index.vue

@ -1,5 +0,0 @@
<template>
<div>
NotFound
</div>
</template>

34
packages/client/src/pages/test/index.vue

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

28
packages/client/src/pages/test/index2.vue

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

1
packages/client/src/pages/test/readme.md

@ -0,0 +1 @@
仅供测试的界面

30
packages/client/src/router/index.ts

@ -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 },
], // ],
}); });
} }

66
packages/client/src/typed-router.d.ts

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

2
packages/client/src/vite-env.d.ts

@ -1,4 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />
/// <reference types="vite-plugin-vue-layouts/client" />
declare module '*.vue' { declare module '*.vue' {
import type { DefineComponent } from 'vue' import type { DefineComponent } from 'vue'

16
packages/client/src/vue.d.ts

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

32
packages/client/tsconfig.json

@ -2,10 +2,13 @@
"compilerOptions": { "compilerOptions": {
"target": "es2022", "target": "es2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "esnext", "module": "esnext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@ -13,14 +16,31 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"jsxImportSource": "vue",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noImplicitThis": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts"], "include": [
"references": [{ "path": "./tsconfig.node.json" }] "src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"components.d.ts",
"auto-imports.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
} }

42
packages/client/vite.config.ts

@ -1,27 +1,63 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import devtoolsJson from 'vite-plugin-devtools-json'; import devtoolsJson from 'vite-plugin-devtools-json';
import VueRouter from 'unplugin-vue-router/vite'
import Layouts from 'vite-plugin-vue-layouts';
import { VueRouterAutoImports } from 'unplugin-vue-router'
import { unheadVueComposablesImports } from '@unhead/vue'
import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver';
import {
MazComponentsResolver,
MazDirectivesResolver,
MazModulesResolver
} from 'maz-ui/resolvers'
import { MazIconsResolver } from '@maz-ui/icons/resolvers'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
cacheDir: '../../node_modules/.vite',
resolve: {
alias: {
'@': resolve(__dirname, 'src')
},
},
build: { build: {
emptyOutDir: true emptyOutDir: true,
},
// https://github.com/posva/unplugin-vue-router/discussions/349#discussioncomment-9043123
ssr: {
noExternal: process.env.NODE_ENV === 'development' ? ['vue-router'] : []
}, },
plugins: [ plugins: [
devtoolsJson(), devtoolsJson(),
VueRouter({
root: resolve(__dirname),
dts: 'src/typed-router.d.ts',
}),
vue(), vue(),
Layouts({
defaultLayout: "base"
}),
Components({ Components({
dts: true, dts: true,
dirs: ['src/components', '../../internal/x/components'], dirs: ['src/components', '../../internal/x/components'],
globsExclude: ["**/_*/**/*"] excludeNames: [/^\_.+/],
resolvers: [
AntDesignXVueResolver(),
MazIconsResolver(),
MazComponentsResolver(),
MazDirectivesResolver(),
],
}), }),
AutoImport({ AutoImport({
dts: true, dts: true,
dtsMode: "overwrite", dtsMode: "overwrite",
resolvers: [MazModulesResolver()],
ignore: ["**/_*/**/*"], ignore: ["**/_*/**/*"],
imports: ['vue', 'vue-router', 'pinia'], imports: ['vue', 'vue-router', 'pinia', VueRouterAutoImports, unheadVueComposablesImports],
dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"], dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"],
vueTemplate: true, vueTemplate: true,
}), }),

14
packages/core/src/SsrMiddleWare.ts

@ -6,6 +6,7 @@ import { ViteDevServer } from 'vite'
import Send from 'koa-send' import Send from 'koa-send'
import type Koa from 'koa' import type Koa from 'koa'
import c2k from 'koa-connect' import c2k from 'koa-connect'
import { transformHtmlTemplate } from "unhead/server";
const isProduction = Env.isProduction const isProduction = Env.isProduction
const base = Env.base const base = Env.base
@ -30,11 +31,10 @@ export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Funct
vite.httpServer?.on("close", () => { vite.httpServer?.on("close", () => {
vite.close() vite.close()
options?.onDevViteClose?.() options?.onDevViteClose?.()
}) })
} else { } else {
// Production mode: serve pre-built static assets. // Production mode: serve pre-built static assets.
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
if (ctx.originalUrl === "/.well-known/appspecific/com.chrome.devtools.json") return await next()
try { try {
await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false }); await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false });
if (ctx.status === 404) { if (ctx.status === 404) {
@ -76,10 +76,12 @@ export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Funct
const rendered = await render(url, manifest, { cookies }) const rendered = await render(url, manifest, { cookies })
const html = template const html = await transformHtmlTemplate(
.replace(`<!--app-head-->`, rendered.head ?? '') rendered.unHead,
.replace(`<!--app-html-->`, rendered.html ?? '') template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
)
ctx.status = 200 ctx.status = 200
ctx.set({ 'Content-Type': 'text/html' }) ctx.set({ 'Content-Type': 'text/html' })
ctx.body = html ctx.body = html

17
packages/server/package.json

@ -9,12 +9,25 @@
}, },
"scripts": {}, "scripts": {},
"devDependencies": { "devDependencies": {
"@types/formidable": "^3.4.5",
"@types/koa": "^3.0.0", "@types/koa": "^3.0.0",
"@types/koa-send": "^4.1.6" "@types/koa-bodyparser": "^4.3.12",
"@types/koa-send": "^4.1.6",
"@types/path-is-absolute": "^1.0.2",
"jsonwebtoken": "^9.0.2"
}, },
"dependencies": { "dependencies": {
"@types/jsonwebtoken": "^9.0.10",
"formidable": "^3.5.4",
"koa": "^3.0.1", "koa": "^3.0.1",
"koa-bodyparser": "^4.4.1",
"koa-connect": "^2.1.0", "koa-connect": "^2.1.0",
"koa-send": "^5.0.1" "koa-send": "^5.0.1",
"koa-session": "^7.0.2",
"log4js": "^6.9.1",
"minimatch": "^10.0.3",
"node-cron": "^4.2.1",
"path-is-absolute": "^2.0.0",
"path-to-regexp": "^8.3.0"
} }
} }

2
packages/server/src/api/main.ts

@ -47,7 +47,7 @@ export function bootstrapServer() {
// ctx.set("Set-Cookie", [setItem]); // ctx.set("Set-Cookie", [setItem]);
// } // }
if (ctx.originalUrl !== "/api/pics/random") return await next(); if (ctx.originalUrl !== "/api/pics/random") return await next();
ctx.body = `Hello World`
const { type, data } = await fetchFirstSuccess([ const { type, data } = await fetchFirstSuccess([
"https://api.miaomc.cn/image/get", "https://api.miaomc.cn/image/get",
]); ]);

14
packages/server/src/app.ts

@ -1,7 +1,17 @@
import { validateEnvironment } from "@/utils/EnvValidator"
import Koa from "koa" import Koa from "koa"
import { logger } from "@/logger"
// 启动前验证环境变量
if (!validateEnvironment()) {
logger.error("环境变量验证失败,应用退出")
process.exit(1)
}
const app = new Koa() const app = new Koa({
asyncLocalStorage: true,
keys: (process.env.SESSION_SECRET || '').split(",").filter(v => !!v).map(s => s.trim())
})
export default app export default app
export { export {

318
packages/server/src/base/BaseController.ts

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

40
packages/server/src/booststap.ts

@ -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(`────────────────────────────────────────`)
}
}) })

11
packages/server/src/env.d.ts

@ -0,0 +1,11 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
SESSION_SECRET: string;
JWT_SECRET: string;
}
}
}
export { };

62
packages/server/src/jobs/index.ts

@ -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);
}
}
};

12
packages/server/src/jobs/jobs/exampleJob.ts

@ -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,
})

63
packages/server/src/jobs/scheduler.ts

@ -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();

61
packages/server/src/logger.ts

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

38
packages/server/src/middleware/Auth/index.ts

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

100
packages/server/src/middleware/Controller/index.ts

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

59
packages/server/src/middleware/ResponseTime/index.ts

@ -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")
}
}

186
packages/server/src/middleware/Send/index.ts

@ -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;

74
packages/server/src/middleware/Send/resolve-path.ts

@ -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;

16
packages/server/src/middleware/Session/index.ts

@ -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);
};

104
packages/server/src/middleware/install.ts

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

51
packages/server/src/modules/Job/controller/index.ts

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

18
packages/server/src/modules/Job/services/index.ts

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

207
packages/server/src/modules/Upload/controller/index.ts

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

165
packages/server/src/utils/EnvValidator.ts

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

35
packages/server/src/utils/R.ts

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

141
packages/server/src/utils/Router.ts

@ -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;

24
packages/server/src/utils/error/ApiError.ts

@ -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 || {},
}
}
}

13
packages/server/src/utils/error/AuthError.ts

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

17
packages/server/src/utils/error/BaseError.ts

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

24
packages/server/src/utils/error/CommonError.ts

@ -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 || {},
}
}
}

7
packages/server/tsconfig.json

@ -23,7 +23,12 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
}, },
"include": [ "include": [
"src/**/*.ts" "src/**/*.ts"

18
scripts/fix-type-router.js

@ -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…
Cancel
Save