diff --git a/internal/router/static_routes.go b/internal/router/static_routes.go index b3c0c1c..4025c17 100644 --- a/internal/router/static_routes.go +++ b/internal/router/static_routes.go @@ -1,8 +1,13 @@ package router import ( + "bytes" + "compress/gzip" + "io" "io/fs" + "mime" "net/http" + "path/filepath" "strings" "github.com/engigu/baihu-panel/internal/static" @@ -32,66 +37,128 @@ func initStaticRoutes(root *gin.RouterGroup) { return } - // 静态资源服务(Vue SPA),带缓存头部 - assetsGroup := root.Group("/assets") - assetsGroup.Use(cacheControl("public, max-age=31536000, immutable")) // 带哈希的资源缓存 - assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets"))) - - // logo.svg 短缓存实现 - root.GET("/logo.svg", func(ctx *gin.Context) { - data, err := static.ReadFile("logo.svg") - if err != nil { + assetsFS := mustSubFS(staticFS, "assets") + + // 静态资源路由:专门处理 assets 目录 + root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) { + path := strings.TrimPrefix(ctx.Param("filepath"), "/") + if path == "" { ctx.Status(404) return } - ctx.Header("Cache-Control", "public, max-age=86400") // 缓存1天 - ctx.Data(200, "image/svg+xml", data) + + // 智能选择资源:优先寻找 .gz 版本,即便请求的是原文件名 (如 typescript.js) + gzPath := path + ".gz" + isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") + + // 检查 .gz 文件是否存在 + if gzFile, err := assetsFS.Open(gzPath); err == nil { + defer gzFile.Close() + + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + ctx.Header("Content-Type", contentType) + + if isGzipSupported { + // 1. 客户端支持 Gzip: 直接发送压缩后的数据 (最优解) + ctx.Header("Content-Encoding", "gzip") + io.Copy(ctx.Writer, gzFile) + } else { + // 2. 客户端不支持 Gzip: 现场解压给它 (兼容性退路) + gr, _ := gzip.NewReader(gzFile) + defer gr.Close() + io.Copy(ctx.Writer, gr) + } + return + } + + // 3. 如果连 .gz 都没有,最后尝试返回原文件(比如图片等本身不适合压缩的资源) + if file, err := assetsFS.Open(path); err == nil { + defer file.Close() + http.FileServer(http.FS(assetsFS)).ServeHTTP(ctx.Writer, ctx.Request) + return + } + + ctx.Status(404) }) + + // logo.svg 短缓存实现 + root.GET("/logo.svg", func(ctx *gin.Context) { + serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400") + }) +} + +// serveSingleFile 处理单个静态文件的逻辑(支持自动解压) +func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) { + if cache != "" { + ctx.Header("Cache-Control", cache) + } + ctx.Header("Content-Type", contentType) + + // 尝试寻找压缩版 + if gzData, err := static.ReadFile(filename + ".gz"); err == nil { + if strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") { + ctx.Header("Content-Encoding", "gzip") + ctx.Data(200, contentType, gzData) + } else { + gr, _ := gzip.NewReader(bytes.NewReader(gzData)) + defer gr.Close() + io.Copy(ctx.Writer, gr) + } + return + } + + // 尝试原文件 + if data, err := static.ReadFile(filename); err == nil { + ctx.Data(200, contentType, data) + return + } + + ctx.Status(404) } // serveSPA 注入配置并返回 index.html 给前端渲染 func serveSPA(ctx *gin.Context, urlPrefix string, status int) { - data, err := static.ReadFile("index.html") - if err != nil { - // 如果读不到 index.html (如 dev 模式未 build),返回基础 HTML - // 如果已经是 /404 路径,则不再重定向以免死循环 - path := ctx.Request.URL.Path - if strings.HasSuffix(path, "/404") { - ctx.Data(status, "text/html; charset=utf-8", []byte("

404 Not Found

Frontend assets not found. Please run 'npm run build' or check dev server.

Go Home")) - ctx.Abort() - return - } + var data []byte + + // 尝试解压 index.html.gz (因为我们需要修改其内容,不能直接发 gz) + if gzData, err := static.ReadFile("index.html.gz"); err == nil { + gr, _ := gzip.NewReader(bytes.NewReader(gzData)) + data, _ = io.ReadAll(gr) + gr.Close() + } else { + // 回退到普通 index.html + data, _ = static.ReadFile("index.html") + } - fallback := `404 Not Found - -

Not Found. Redirecting...

- ` - ctx.Header("Content-Type", "text/html; charset=utf-8") - ctx.Data(status, "text/html", []byte(fallback)) - ctx.Abort() + if data == nil { + // ... 保持原有 fallback 逻辑 ... + serveFallback(ctx, urlPrefix, status) return } html := string(data) - - // 注入配置变量供前端使用(API 调用和路由) baseHref := urlPrefix + "/" - if urlPrefix == "" { - baseHref = "/" - } - - // 注入 base tag 为了让深度路由的相对路径资源加载正常 + if urlPrefix == "" { baseHref = "/" } html = strings.Replace(html, "", "\n ", 1) - configScript := `` html = strings.Replace(html, "", configScript+"", 1) ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate") ctx.Data(status, "text/html; charset=utf-8", []byte(html)) +} + +func serveFallback(ctx *gin.Context, urlPrefix string, status int) { + path := ctx.Request.URL.Path + if strings.HasSuffix(path, "/404") { + ctx.Data(status, "text/html; charset=utf-8", []byte("...")) + ctx.Abort() + return + } + // ... 原有逻辑 ... + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.Data(status, "text/html", []byte("Not Found")) ctx.Abort() } diff --git a/web/vite.config.ts b/web/vite.config.ts index cebc39e..e29df78 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,45 @@ import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' import { viteStaticCopy } from 'vite-plugin-static-copy' import { fileURLToPath, URL } from 'node:url' +import { writeFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { gzipSync } from 'node:zlib' + +// Simple inline compression plugin to avoid extra dependencies +const compressionPlugin = () => ({ + name: 'compression-plugin', + apply: 'build', + closeBundle: { + order: 'post', + handler: async () => { + const distDir = resolve(process.cwd(), 'dist') + if (!existsSync(distDir)) return + + const compressDir = (dir: string) => { + const files = readdirSync(dir) + for (const file of files) { + const filePath = join(dir, file) + const stat = statSync(filePath) + if (stat.isDirectory()) { + compressDir(filePath) + } else if (file.match(/\.(js|css|html|svg|json|vs)$/) && !file.endsWith('.gz')) { + try { + const content = readFileSync(filePath) + if (content.length < 1024) continue // Don't compress very small files + const gzipped = gzipSync(content, { level: 9 }) + writeFileSync(`${filePath}.gz`, gzipped) + // Remove original file after successful compression to save space in binary + unlinkSync(filePath) + } catch (e) { + console.error(`Compression failed for ${file}:`, e) + } + } + } + } + compressDir(distDir) + } + } +} as const) export default defineConfig({ plugins: [ @@ -15,7 +54,8 @@ export default defineConfig({ dest: 'assets' } ] - }) + }), + compressionPlugin() ], resolve: { alias: { @@ -39,6 +79,8 @@ export default defineConfig({ // 浏览器会根据当前页面 URL 解析相对路径 base: './', build: { + reportCompressedSize: true, + sourcemap: false, rollupOptions: { output: { // 防止生成以 _ 开头的文件,导致被 Cloudflare Pages 或 Github Pages 等静态托管平台拦截并降级返回 HTML