diff --git a/README.md b/README.md index 6d83d19..8253342 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ # 基于koa实现的简易ssr -- https://segmentfault.com/a/1190000042389086 \ No newline at end of file +- https://segmentfault.com/a/1190000042389086 + + +## 试试grpc,实现node与python通信,扩展更多的功能。 +https://grpc.org.cn/docs/languages/node/quickstart/ +https://www.doubao.com/chat/23869592666505474 \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 71e79af..b602fbb 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/internal/helper/src/env.ts b/internal/helper/src/env.ts index a208659..6307ec6 100644 --- a/internal/helper/src/env.ts +++ b/internal/helper/src/env.ts @@ -2,10 +2,12 @@ const isProduction = process.env.NODE_ENV === 'production' const port = process.env.PORT || 5173 const base = process.env.BASE || '/' +const LOG_DIR = process.env.LOG_DIR || 'logs' export const Env = { isProduction, port: Number(port), base, + LOG_DIR, } \ No newline at end of file diff --git a/internal/x/composables/useShareContext.ts b/internal/x/composables/useShareContext.ts new file mode 100644 index 0000000..34f242c --- /dev/null +++ b/internal/x/composables/useShareContext.ts @@ -0,0 +1,10 @@ +import { useSSRContext } from "vue" + +export function useShareCache(): Map | null { + if (typeof window === 'undefined') { + const ssrContext = useSSRContext() + return ssrContext?.cache || null + } else { + return (window as any).__SSR_CONTEXT__?.cache || null + } +} \ No newline at end of file diff --git a/package.json b/package.json index 61f6249..1269c4e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "internal/*" ], "scripts": { + "postinstall": "node scripts/fix-type-router.js", "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", "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" @@ -20,10 +22,11 @@ "helper": "workspace:*", "server": "workspace:*", "unplugin-vue-components": "^29.1.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "x": "workspace:*" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.9.3" }, "dependencies": { "koa-compose": "^4.1.0", diff --git a/packages/client/auto-imports.d.ts b/packages/client/auto-imports.d.ts index 055e7ca..459aa18 100644 --- a/packages/client/auto-imports.d.ts +++ b/packages/client/auto-imports.d.ts @@ -25,6 +25,7 @@ declare global { const h: typeof import('vue')['h'] const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext'] const inject: typeof import('vue')['inject'] + const injectHead: typeof import('@unhead/vue')['injectHead'] const isProxy: typeof import('vue')['isProxy'] const isReactive: typeof import('vue')['isReactive'] const isReadonly: typeof import('vue')['isReadonly'] @@ -82,11 +83,18 @@ declare global { const useCssVars: typeof import('vue')['useCssVars'] const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch'] 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 useLink: typeof import('vue-router')['useLink'] const useModel: typeof import('vue')['useModel'] const useRoute: typeof import('vue-router')['useRoute'] 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 useTemplateRef: typeof import('vue')['useTemplateRef'] const watch: typeof import('vue')['watch'] @@ -131,6 +139,7 @@ declare module 'vue' { readonly h: UnwrapRef readonly hydrateSSRContext: UnwrapRef readonly inject: UnwrapRef + readonly injectHead: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef readonly isReadonly: UnwrapRef @@ -188,11 +197,18 @@ declare module 'vue' { readonly useCssVars: UnwrapRef readonly useFetch: UnwrapRef readonly useGlobal: UnwrapRef + readonly useHead: UnwrapRef + readonly useHeadSafe: UnwrapRef readonly useId: UnwrapRef readonly useLink: UnwrapRef readonly useModel: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef + readonly useSeoMeta: UnwrapRef + readonly useServerHead: UnwrapRef + readonly useServerHeadSafe: UnwrapRef + readonly useServerSeoMeta: UnwrapRef + readonly useShareCache: UnwrapRef readonly useSlots: UnwrapRef readonly useTemplateRef: UnwrapRef readonly watch: UnwrapRef diff --git a/packages/client/components.d.ts b/packages/client/components.d.ts index 2bb3718..cb7b8d4 100644 --- a/packages/client/components.d.ts +++ b/packages/client/components.d.ts @@ -9,12 +9,17 @@ export {} declare module 'vue' { export interface GlobalComponents { 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'] CookieDemo: typeof import('./src/components/CookieDemo.vue')['default'] DataFetch: typeof import('./src/components/DataFetch.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'] RouterView: typeof import('vue-router')['RouterView'] 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'] } } diff --git a/packages/client/index.html b/packages/client/index.html index 0ec77f1..789957a 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -1,17 +1,10 @@ - - - - - - - Vite + Vue + TS + + - - - + +
- - - - \ No newline at end of file + + + diff --git a/packages/client/package.json b/packages/client/package.json index 40cdbff..ca92079 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,17 +11,22 @@ "unplugin-vue-components": "^29.1.0", "vue-tsc": "^3.1.0" }, - "peerDependencies": { - "typescript": "^5.0.0" - }, "dependencies": { + "@maz-ui/icons": "^4.1.3", + "@maz-ui/themes": "^4.1.5", + "@unhead/vue": "^2.0.17", "@vitejs/plugin-vue": "^6.0.1", + "ant-design-x-vue": "^1.3.2", "dompurify": "^3.2.7", "htmlparser2": "^10.0.0", "marked": "^16.3.0", + "maz-ui": "^4.1.6", + "quill": "^2.0.3", "unplugin-auto-import": "^20.2.0", + "unplugin-vue-router": "^0.15.0", + "vite-plugin-vue-layouts": "^0.11.0", "vue": "^3.5.22", - "vue-router": "^4.5.1", - "x": "workspace:*" + "vue-final-modal": "^4.5.5", + "vue-router": "^4.5.1" } } diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue index 1c0065b..121ff3d 100644 --- a/packages/client/src/App.vue +++ b/packages/client/src/App.vue @@ -1,20 +1,4 @@ diff --git a/packages/client/src/assets/styles/css/reset.css b/packages/client/src/assets/styles/css/reset.css new file mode 100644 index 0000000..af94440 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/packages/client/src/components/QuillEditor/_Editor.vue b/packages/client/src/components/QuillEditor/_Editor.vue new file mode 100644 index 0000000..85d2202 --- /dev/null +++ b/packages/client/src/components/QuillEditor/_Editor.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/client/src/components/QuillEditor/index.vue b/packages/client/src/components/QuillEditor/index.vue new file mode 100644 index 0000000..c76adb8 --- /dev/null +++ b/packages/client/src/components/QuillEditor/index.vue @@ -0,0 +1,10 @@ + + + diff --git a/packages/client/src/components/QuillEditor/useQuill/index.ts b/packages/client/src/components/QuillEditor/useQuill/index.ts new file mode 100644 index 0000000..c8c338b --- /dev/null +++ b/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 | Readonly>; + onTextChange?: (delta: any, oldDelta: any, source: any) => void; + quillOptions?: QuillOptions; + handleImageUpload?: (file: FileList) => Promise; +} + +const defalutOption: Partial = { + 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((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, + }; +} \ No newline at end of file diff --git a/packages/client/src/components/QuillEditor/useQuill/quill-shim.ts b/packages/client/src/components/QuillEditor/useQuill/quill-shim.ts new file mode 100644 index 0000000..32a9303 --- /dev/null +++ b/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 diff --git a/packages/client/src/components/QuillEditor/useQuill/quill-video.ts b/packages/client/src/components/QuillEditor/useQuill/quill-video.ts new file mode 100644 index 0000000..b8c0423 --- /dev/null +++ b/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 `${video}` + } +} +Video.blotName = 'video' +// Video.className = 'ql-video' // 可添加样式,看主要需要 +Video.tagName = 'video' // 用video标签替换iframe + +Quill.register(Video, true); diff --git a/packages/client/src/components/ThemeDemo.vue b/packages/client/src/components/ThemeDemo.vue new file mode 100644 index 0000000..8cbe852 --- /dev/null +++ b/packages/client/src/components/ThemeDemo.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/client/src/entry-client.ts b/packages/client/src/entry-client.ts index 142cc0d..10f7d3d 100644 --- a/packages/client/src/entry-client.ts +++ b/packages/client/src/entry-client.ts @@ -1,5 +1,13 @@ import { createApp } from "./main" 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 上下文(如果存在) let ssrContext = null @@ -14,6 +22,27 @@ if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) { // 使用相同的 SSR 上下文创建应用 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) { pinia.state.value = ssrContext.piniaState } @@ -21,7 +50,7 @@ if (ssrContext) { // 等待路由准备就绪,然后挂载应用 router.isReady().then(() => { console.log('[Client] 路由已准备就绪,挂载应用') - app.mount('#app') + app.mount('#app', true) // 水合完成后清除 SSR 上下文 clearSSRContext() diff --git a/packages/client/src/entry-server.ts b/packages/client/src/entry-server.ts index 05b6e56..9751534 100644 --- a/packages/client/src/entry-server.ts +++ b/packages/client/src/entry-server.ts @@ -2,7 +2,7 @@ import { renderToString } from 'vue/server-renderer' import { createApp } from './main' import { createSSRContext } from 'x/composables/ssrContext' import { basename } from 'node:path' - +import { createHead } from '@unhead/vue/server' export async function render(url: string, manifest: any, init?: { cookies?: Record }) { // 创建 SSR 上下文,包含数据缓存与 cookies @@ -14,6 +14,46 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco // 将 SSR 上下文传递给应用创建函数 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 设置路由 await router.isReady(); // 等待路由准备完成 @@ -28,24 +68,23 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco // 使用更安全的方式序列化 Map const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : [] const ssrData = JSON.stringify(cacheEntries) - const cookieInit = JSON.stringify(ssrContext.cookies || {}) // @ts-ignore const preloadLinks = renderPreloadLinks(ctx.modules, manifest) console.log('[SSR] 序列化缓存数据:', cacheEntries) + const head = ` ${preloadLinks} ` - return { html, head, setCookies: ssrContext.setCookies || [] } + return { html, head, unHead, setCookies: ssrContext.setCookies || [] } } function renderPreloadLinks(modules: any, manifest: any) { diff --git a/packages/client/src/layouts/base.vue b/packages/client/src/layouts/base.vue new file mode 100644 index 0000000..ec52bf2 --- /dev/null +++ b/packages/client/src/layouts/base.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index d7f1f4b..fb3e109 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -2,17 +2,17 @@ import { createSSRApp } from 'vue' import App from './App.vue' import createSSRRouter from './router'; 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) { const app = createSSRApp(App) const router = createSSRRouter() const pinia = createPinia() + const vfm = createVfm() as any app.use(router) app.use(pinia) + app.use(vfm) // 如果有 SSR 上下文,注入到应用中 if (ssrContext) { diff --git a/packages/client/src/pages/_M.vue b/packages/client/src/pages/_M.vue new file mode 100644 index 0000000..2515a89 --- /dev/null +++ b/packages/client/src/pages/_M.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/client/src/pages/about/index.vue b/packages/client/src/pages/about/index.vue deleted file mode 100644 index 597f5c1..0000000 --- a/packages/client/src/pages/about/index.vue +++ /dev/null @@ -1,11 +0,0 @@ - - \ No newline at end of file diff --git a/packages/client/src/pages/home/index.vue b/packages/client/src/pages/home/index.vue deleted file mode 100644 index 573de29..0000000 --- a/packages/client/src/pages/home/index.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/packages/client/src/pages/index.vue b/packages/client/src/pages/index.vue new file mode 100644 index 0000000..42643a0 --- /dev/null +++ b/packages/client/src/pages/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/client/src/pages/not-found/index.vue b/packages/client/src/pages/not-found/index.vue deleted file mode 100644 index 5ef1232..0000000 --- a/packages/client/src/pages/not-found/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/packages/client/src/pages/test/index.vue b/packages/client/src/pages/test/index.vue new file mode 100644 index 0000000..3009320 --- /dev/null +++ b/packages/client/src/pages/test/index.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/client/src/pages/test/index2.vue b/packages/client/src/pages/test/index2.vue new file mode 100644 index 0000000..526cea6 --- /dev/null +++ b/packages/client/src/pages/test/index2.vue @@ -0,0 +1,28 @@ + + diff --git a/packages/client/src/pages/test/readme.md b/packages/client/src/pages/test/readme.md new file mode 100644 index 0000000..752f6e6 --- /dev/null +++ b/packages/client/src/pages/test/readme.md @@ -0,0 +1 @@ +仅供测试的界面 \ No newline at end of file diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 82c1eea..7470420 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -1,16 +1,28 @@ -// src/router.js -import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'; -import NotFound from '../pages/not-found/index.vue'; +// https://uvr.esm.is/guide/extending-routes.html#definepage + +import { createRouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router'; +// import NotFound from '../pages/not-found/index.vue'; +import { routes } from 'vue-router/auto-routes' +import { setupLayouts } from 'virtual:generated-layouts' + +// import BaseLayout from '@/layouts/base.vue'; export default function createSSRRouter() { return createRouter({ history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式 - routes: [ - { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, - { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, + routes: setupLayouts(routes), + // routes: [ + // { + // name: "BaseLayout", path: '', component: BaseLayout, children: [ + // { name: "home", path: '', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, + // { name: "about", path: 'about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, + // ] + // }, + // // { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, + // // { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, - // 404 - { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, - ], + // // 404 + // { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, + // ], }); } \ No newline at end of file diff --git a/packages/client/src/typed-router.d.ts b/packages/client/src/typed-router.d.ts new file mode 100644 index 0000000..1924560 --- /dev/null +++ b/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, Record>, + '/_M': RouteRecordInfo<'/_M', '/_M', Record, Record>, + 'test': RouteRecordInfo<'test', '/test', Record, Record>, + 'about': RouteRecordInfo<'about', '/test/index2', Record, Record>, + } + + /** + * 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 ) + * + * @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 = + _RouteFileInfoMap extends Record + ? Info['routes'] + : keyof RouteNamedMap +} diff --git a/packages/client/src/vite-env.d.ts b/packages/client/src/vite-env.d.ts index 323c78a..a4d9ae6 100644 --- a/packages/client/src/vite-env.d.ts +++ b/packages/client/src/vite-env.d.ts @@ -1,4 +1,6 @@ /// +/// +/// declare module '*.vue' { import type { DefineComponent } from 'vue' diff --git a/packages/client/src/vue.d.ts b/packages/client/src/vue.d.ts index de7be77..89f70b6 100644 --- a/packages/client/src/vue.d.ts +++ b/packages/client/src/vue.d.ts @@ -1,10 +1,20 @@ +import 'vue-router' + +// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明 export { } -declare module 'vue' { - export interface ComponentCustomProperties { +declare module 'vue-router' { + interface RouteMeta { + // 是可选的 + cache?: boolean + } +} + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { $ssrContext?: Record } - export interface ComponentInternalInstance { + interface ComponentInternalInstance { _nuxtClientOnly?: boolean } -} \ No newline at end of file +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index fc06120..cebd767 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -2,10 +2,13 @@ "compilerOptions": { "target": "es2022", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], "module": "esnext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -13,14 +16,31 @@ "moduleDetection": "force", "noEmit": true, "jsx": "preserve", - + "jsxImportSource": "vue", /* Linting */ "strict": true, + "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts"], - "references": [{ "path": "./tsconfig.node.json" }] -} + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + "components.d.ts", + "auto-imports.d.ts" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 02f84e2..1949a3d 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,27 +1,63 @@ import { defineConfig } from 'vite' +import { resolve } from 'node:path' import vue from '@vitejs/plugin-vue' import Components from 'unplugin-vue-components/vite' import AutoImport from 'unplugin-auto-import/vite' 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/ export default defineConfig({ + cacheDir: '../../node_modules/.vite', + resolve: { + alias: { + '@': resolve(__dirname, 'src') + }, + }, 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: [ devtoolsJson(), + VueRouter({ + root: resolve(__dirname), + dts: 'src/typed-router.d.ts', + }), vue(), + Layouts({ + defaultLayout: "base" + }), Components({ dts: true, dirs: ['src/components', '../../internal/x/components'], - globsExclude: ["**/_*/**/*"] + excludeNames: [/^\_.+/], + resolvers: [ + AntDesignXVueResolver(), + MazIconsResolver(), + MazComponentsResolver(), + MazDirectivesResolver(), + ], }), AutoImport({ dts: true, dtsMode: "overwrite", + resolvers: [MazModulesResolver()], ignore: ["**/_*/**/*"], - imports: ['vue', 'vue-router', 'pinia'], + imports: ['vue', 'vue-router', 'pinia', VueRouterAutoImports, unheadVueComposablesImports], dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"], vueTemplate: true, }), diff --git a/packages/core/src/SsrMiddleWare.ts b/packages/core/src/SsrMiddleWare.ts index b6e8388..4be0786 100644 --- a/packages/core/src/SsrMiddleWare.ts +++ b/packages/core/src/SsrMiddleWare.ts @@ -6,6 +6,7 @@ import { ViteDevServer } from 'vite' import Send from 'koa-send' import type Koa from 'koa' import c2k from 'koa-connect' +import { transformHtmlTemplate } from "unhead/server"; const isProduction = Env.isProduction const base = Env.base @@ -27,14 +28,13 @@ export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Funct base, }) app.use(c2k(vite.middlewares)) - vite.httpServer?.on("close", () => { + vite.httpServer?.on("close", () => { vite.close() options?.onDevViteClose?.() - }) + }) } else { // Production mode: serve pre-built static assets. app.use(async (ctx, next) => { - if (ctx.originalUrl === "/.well-known/appspecific/com.chrome.devtools.json") return await next() try { await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false }); 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 html = template - .replace(``, rendered.head ?? '') - .replace(``, rendered.html ?? '') - + const html = await transformHtmlTemplate( + rendered.unHead, + template + .replace(``, rendered.head ?? '') + .replace(``, rendered.html ?? '') + ) ctx.status = 200 ctx.set({ 'Content-Type': 'text/html' }) ctx.body = html diff --git a/packages/server/package.json b/packages/server/package.json index 3d6af73..6da9a5e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,12 +9,25 @@ }, "scripts": {}, "devDependencies": { + "@types/formidable": "^3.4.5", "@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": { + "@types/jsonwebtoken": "^9.0.10", + "formidable": "^3.5.4", "koa": "^3.0.1", + "koa-bodyparser": "^4.4.1", "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" } } diff --git a/packages/server/src/api/main.ts b/packages/server/src/api/main.ts index 3221941..63dcb0b 100644 --- a/packages/server/src/api/main.ts +++ b/packages/server/src/api/main.ts @@ -47,7 +47,7 @@ export function bootstrapServer() { // ctx.set("Set-Cookie", [setItem]); // } if (ctx.originalUrl !== "/api/pics/random") return await next(); - ctx.body = `Hello World` + const { type, data } = await fetchFirstSuccess([ "https://api.miaomc.cn/image/get", ]); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 38d9780..14b26e0 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,7 +1,17 @@ - +import { validateEnvironment } from "@/utils/EnvValidator" 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 { diff --git a/packages/server/src/base/BaseController.ts b/packages/server/src/base/BaseController.ts new file mode 100644 index 0000000..29e3fd5 --- /dev/null +++ b/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 } \ No newline at end of file diff --git a/packages/server/src/booststap.ts b/packages/server/src/booststap.ts index c8432ce..4a1ebbf 100644 --- a/packages/server/src/booststap.ts +++ b/packages/server/src/booststap.ts @@ -1,20 +1,38 @@ import app from "./app" import { bootstrapServer } from "./api/main" -import { SsrMiddleWare } from "core/SsrMiddleWare" +import LoadMiddleware from "./middleware/install" import { Env } from "helper/env" +import os from "node:os" + +import "./jobs" bootstrapServer() -SsrMiddleWare(app, { - onDevViteClose() { - console.log("Vite dev server closed") - if (server) { - server.close() - console.log('Server closed') - } - } -}) +await LoadMiddleware(app) const server = app.listen(Env.port, () => { - console.log(`Server started at http://localhost:${Env.port}`) + const address = server.address() + if (address != null && typeof address !== 'string') { + const port = address.port + // 获取本地 IP + const getLocalIP = () => { + const interfaces = os.networkInterfaces() + for (const name of Object.keys(interfaces)) { + if (!interfaces[name]) continue + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address + } + } + } + return "localhost" + } + const localIP = getLocalIP() + console.log(`────────────────────────────────────────`) + console.log(`🚀 服务器已启动`) + console.log(` 本地访问: http://localhost:${port}`) + console.log(` 局域网: http://${localIP}:${port}`) + console.log(` 启动时间: ${new Date().toLocaleString()}`) + console.log(`────────────────────────────────────────`) + } }) diff --git a/packages/server/src/env.d.ts b/packages/server/src/env.d.ts new file mode 100644 index 0000000..4c91033 --- /dev/null +++ b/packages/server/src/env.d.ts @@ -0,0 +1,11 @@ + +declare global { + namespace NodeJS { + interface ProcessEnv { + SESSION_SECRET: string; + JWT_SECRET: string; + } + } +} + +export { }; diff --git a/packages/server/src/jobs/index.ts b/packages/server/src/jobs/index.ts new file mode 100644 index 0000000..19c1c19 --- /dev/null +++ b/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 = {}; + +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); + } + } +}; diff --git a/packages/server/src/jobs/jobs/exampleJob.ts b/packages/server/src/jobs/jobs/exampleJob.ts new file mode 100644 index 0000000..d1610d4 --- /dev/null +++ b/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, +}) diff --git a/packages/server/src/jobs/scheduler.ts b/packages/server/src/jobs/scheduler.ts new file mode 100644 index 0000000..5f2e36e --- /dev/null +++ b/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; + 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(); diff --git a/packages/server/src/logger.ts b/packages/server/src/logger.ts new file mode 100644 index 0000000..6d7daea --- /dev/null +++ b/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'); diff --git a/packages/server/src/middleware/Auth/index.ts b/packages/server/src/middleware/Auth/index.ts new file mode 100644 index 0000000..fcc2426 --- /dev/null +++ b/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() + } +} diff --git a/packages/server/src/middleware/Controller/index.ts b/packages/server/src/middleware/Controller/index.ts new file mode 100644 index 0000000..6b19bd6 --- /dev/null +++ b/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) + } +} diff --git a/packages/server/src/middleware/ResponseTime/index.ts b/packages/server/src/middleware/ResponseTime/index.ts new file mode 100644 index 0000000..db8b876 --- /dev/null +++ b/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") + } +} diff --git a/packages/server/src/middleware/Send/index.ts b/packages/server/src/middleware/Send/index.ts new file mode 100644 index 0000000..5ac84a0 --- /dev/null +++ b/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} 文件是否存在 + */ +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; diff --git a/packages/server/src/middleware/Send/resolve-path.ts b/packages/server/src/middleware/Send/resolve-path.ts new file mode 100644 index 0000000..fd73f71 --- /dev/null +++ b/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; diff --git a/packages/server/src/middleware/Session/index.ts b/packages/server/src/middleware/Session/index.ts new file mode 100644 index 0000000..9ea61f0 --- /dev/null +++ b/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); +}; diff --git a/packages/server/src/middleware/install.ts b/packages/server/src/middleware/install.ts new file mode 100644 index 0000000..20d21a2 --- /dev/null +++ b/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) + +} \ No newline at end of file diff --git a/packages/server/src/modules/Job/controller/index.ts b/packages/server/src/modules/Job/controller/index.ts new file mode 100644 index 0000000..5e059a7 --- /dev/null +++ b/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 diff --git a/packages/server/src/modules/Job/services/index.ts b/packages/server/src/modules/Job/services/index.ts new file mode 100644 index 0000000..16c6180 --- /dev/null +++ b/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 diff --git a/packages/server/src/modules/Upload/controller/index.ts b/packages/server/src/modules/Upload/controller/index.ts new file mode 100644 index 0000000..b51a312 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/packages/server/src/utils/EnvValidator.ts b/packages/server/src/utils/EnvValidator.ts new file mode 100644 index 0000000..76360cd --- /dev/null +++ b/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 = {} + + 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 = {} + + 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 +} \ No newline at end of file diff --git a/packages/server/src/utils/R.ts b/packages/server/src/utils/R.ts new file mode 100644 index 0000000..f5f2b36 --- /dev/null +++ b/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(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 diff --git a/packages/server/src/utils/Router.ts b/packages/server/src/utils/Router.ts new file mode 100644 index 0000000..a442cf4 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/packages/server/src/utils/error/ApiError.ts b/packages/server/src/utils/error/ApiError.ts new file mode 100644 index 0000000..471af4e --- /dev/null +++ b/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 || {}, + } + } +} diff --git a/packages/server/src/utils/error/AuthError.ts b/packages/server/src/utils/error/AuthError.ts new file mode 100644 index 0000000..4b4b22b --- /dev/null +++ b/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 + } +} diff --git a/packages/server/src/utils/error/BaseError.ts b/packages/server/src/utils/error/BaseError.ts new file mode 100644 index 0000000..982c57f --- /dev/null +++ b/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 \ No newline at end of file diff --git a/packages/server/src/utils/error/CommonError.ts b/packages/server/src/utils/error/CommonError.ts new file mode 100644 index 0000000..30d7011 --- /dev/null +++ b/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 || {}, + } + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 90b3e96..cf9a923 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -23,7 +23,12 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + }, }, "include": [ "src/**/*.ts" diff --git a/scripts/fix-type-router.js b/scripts/fix-type-router.js new file mode 100644 index 0000000..9cf6e91 --- /dev/null +++ b/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.");