// Package webui 通过 go:embed 把前端资源打进二进制。 // // 前端用 Tailwind v4 + FlyonUI 构建:FlyonUI 官方不支持 CDN 方式 // (样式在构建期生成),所以这里使用构建产物,用户无需 Node 环境。 // 构建命令见 scripts/build-webui.sh,产物在 internal/webui/dist 下。 package webui import ( "crypto/sha256" "embed" "encoding/hex" "fmt" "io/fs" "net/http" "strings" ) // dist 是前端构建产物。 // //go:embed all:dist var dist embed.FS // assets 保存资源名 → 内容;assetETags 保存内容指纹用于 ETag。 var ( assets map[string][]byte assetETags map[string]string ) func init() { fsys, err := fs.Sub(dist, "dist") if err != nil { return } assets = make(map[string][]byte, 8) assetETags = make(map[string]string, 8) _ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } data, err := fs.ReadFile(fsys, path) if err != nil { return nil } assets[path] = data sum := sha256.Sum256(data) assetETags[path] = `"` + hex.EncodeToString(sum[:8]) + `"` return nil }) } // contentTypes 是各静态资源的 MIME 类型。 var contentTypes = map[string]string{ ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".html": "text/html; charset=utf-8", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".png": "image/png", ".woff2": "font/woff2", } // IndexHandler 输出内嵌的 index.html。 // // 刻意不缓存 HTML:它引用 app.js,一旦被缓存,升级二进制后浏览器仍会 // 加载旧脚本,表现为界面数据莫名其妙地缺失或显示 undefined。 func IndexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data, ok := assets["index.html"] if !ok { http.Error(w, "前端资源缺失,请先执行 make css", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(data) } } // AssetHandler 提供 /static/ 下的静态资源。 // // 用内容指纹做 ETag 并允许缓存,但每次回源校验(no-cache)。 // 这样既有 304 的高效,又不会在升级二进制后继续使用旧资源—— // 仅设 max-age 而不带指纹的策略在内嵌场景下会长期给出过期界面。 func AssetHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/static/") if name == "" { http.NotFound(w, r) return } data, ok := assets[name] if !ok { http.NotFound(w, r) return } etag := assetETags[name] w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "no-cache") if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) { w.WriteHeader(http.StatusNotModified) return } if ct, ok := contentTypes[extOf(name)]; ok { w.Header().Set("Content-Type", ct) } w.Header().Set("Content-Length", fmt.Sprint(len(data))) _, _ = w.Write(data) } } func extOf(name string) string { if i := strings.LastIndex(name, "."); i >= 0 { return name[i:] } return "" } // AssetNames 返回已嵌入的资源名,供测试与健康检查使用。 func AssetNames() []string { out := make([]string, 0, len(assets)) for name := range assets { out = append(out, name) } return out }