feat: implement custom WebUI support with dynamic frontend replacement

- Add WebUI service and controller for managing custom frontend packages

- Allow overriding default static assets when custom WebUI is activated

- Add Make commands for packing custom WebUI distributions

- Update UI settings to support uploading, activating and deleting WebUIs

- Document WebUI feature in README and changelog
This commit is contained in:
duorameng
2026-05-29 11:49:01 +08:00
parent 41af3cf9e0
commit 323a58e041
19 changed files with 1004 additions and 35 deletions
+7 -5
View File
@@ -30,6 +30,7 @@ const (
KeyPageSize = "page_size"
KeyCookieDays = "cookie_days"
KeyOpenapiToken = "openapi_token"
KeyActiveWebUI = "active_webui"
// Security Settings Key 常量
KeySecret = "secret"
@@ -201,11 +202,12 @@ var DefaultIcon = `<svg t="1766107903919" class="icon" viewBox="0 0 1024 1024" v
// DefaultSettings 默认系统设置
var DefaultSettings = map[string]map[string]string{
SectionSite: {
KeyTitle: "白虎面板",
KeySubtitle: "极致轻量、高性能的自动化任务调度平台",
KeyIcon: DefaultIcon,
KeyPageSize: "10",
KeyCookieDays: "7",
KeyTitle: "白虎面板",
KeySubtitle: "极致轻量、高性能的自动化任务调度平台",
KeyIcon: DefaultIcon,
KeyPageSize: "10",
KeyCookieDays: "7",
KeyActiveWebUI: "default",
},
SectionScheduler: {
KeyWorkerCount: "4",
+93
View File
@@ -0,0 +1,93 @@
package controllers
import (
"os"
"path/filepath"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/utils"
"github.com/gin-gonic/gin"
)
type WebUIController struct {
webuiService *services.WebUIService
}
func NewWebUIController(webuiService *services.WebUIService) *WebUIController {
return &WebUIController{
webuiService: webuiService,
}
}
// GetWebUIs 获取所有WebUI
func (c *WebUIController) GetWebUIs(ctx *gin.Context) {
webuis, err := c.webuiService.GetWebUIs()
if err != nil {
utils.ServerError(ctx, err.Error())
return
}
utils.Success(ctx, webuis)
}
// UploadWebUI 上传新WebUI
func (c *WebUIController) UploadWebUI(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
utils.BadRequest(ctx, "获取上传文件失败")
return
}
// 临时保存上传的文件到挂载目录,避免 /tmp 跨分区移动或权限问题
tmpDir := filepath.Join(constant.DataDir, "tmp")
os.MkdirAll(tmpDir, 0755)
tmpFile := filepath.Join(tmpDir, file.Filename)
if err := ctx.SaveUploadedFile(file, tmpFile); err != nil {
utils.ServerError(ctx, "保存临时文件失败")
return
}
defer os.Remove(tmpFile) // 自动清理临时文件
webuiName, err := c.webuiService.ExtractWebUI(tmpFile)
if err != nil {
utils.BadRequest(ctx, err.Error())
return
}
utils.Success(ctx, gin.H{"message": "WebUI上传成功", "webui": webuiName})
}
// SetActiveWebUI 切换活动WebUI
func (c *WebUIController) SetActiveWebUI(ctx *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.BadRequest(ctx, "无效的请求参数")
return
}
if err := c.webuiService.SetActiveWebUI(req.Name); err != nil {
utils.ServerError(ctx, err.Error())
return
}
utils.Success(ctx, gin.H{"message": "WebUI已切换成功,部分页面可能需要刷新"})
}
// DeleteWebUI 删除自定义WebUI
func (c *WebUIController) DeleteWebUI(ctx *gin.Context) {
name := ctx.Param("name")
if name == "" {
utils.BadRequest(ctx, "未提供WebUI名称")
return
}
if err := c.webuiService.DeleteWebUI(name); err != nil {
utils.BadRequest(ctx, err.Error())
return
}
utils.Success(ctx, gin.H{"message": "WebUI已删除"})
}
+11
View File
@@ -64,6 +64,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
registerNotificationRoutes(adminOnly, c)
registerAppLogRoutes(adminOnly, c)
registerSystemWSRoutes(adminOnly, c)
registerWebUIRoutes(adminOnly, c)
}
}
@@ -276,3 +277,13 @@ func initAgentAPIRoutes(root *gin.RouterGroup, c *Controllers) {
agentAPI.GET("/ws", c.Agent.WSConnect) // WebSocket 连接
}
}
func registerWebUIRoutes(g *gin.RouterGroup, c *Controllers) {
webuiGroup := g.Group("/webui")
{
webuiGroup.GET("", c.WebUI.GetWebUIs)
webuiGroup.POST("/upload", c.WebUI.UploadWebUI)
webuiGroup.PUT("/active", c.WebUI.SetActiveWebUI)
webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI)
}
}
+1
View File
@@ -63,6 +63,7 @@ func RegisterControllers() *Controllers {
Notification: controllers.NewNotificationController(),
AppLog: controllers.NewAppLogController(),
SystemWS: controllers.NewSystemWSController(),
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
}
}
+1
View File
@@ -30,6 +30,7 @@ type Controllers struct {
Notification *controllers.NotificationController
AppLog *controllers.AppLogController
SystemWS *controllers.SystemWSController
WebUI *controllers.WebUIController
}
func Setup(c *Controllers) *gin.Engine {
+37 -26
View File
@@ -25,12 +25,38 @@ func cacheControl(value string) gin.HandlerFunc {
}
}
func initStaticRoutes(root *gin.RouterGroup) {
staticFS := static.GetFS()
if staticFS == nil {
return
func openFileWithWebui(filename string) (fs.File, error) {
webuiSvc := services.NewWebUIService(services.NewSettingsService())
if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil {
// 如果启用了定义的前端包,去取定义的路径
return customFS.Open(filename)
}
// 如果是默认的,取默认路径
defaultFS := static.GetFS()
if defaultFS == nil {
return nil, fs.ErrNotExist
}
return defaultFS.Open(filename)
}
func readFileWithWebui(filename string) ([]byte, error) {
webuiSvc := services.NewWebUIService(services.NewSettingsService())
if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil {
// 如果启用了定义的前端包,去取定义的路径
return fs.ReadFile(customFS, filename)
}
// 如果是默认的,取默认路径
defaultFS := static.GetFS()
if defaultFS == nil {
return nil, fs.ErrNotExist
}
return fs.ReadFile(defaultFS, filename)
}
func initStaticRoutes(root *gin.RouterGroup) {
// 专门处理 /assets 目录下的资源
root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) {
fullPath := "assets" + ctx.Param("filepath")
@@ -56,7 +82,7 @@ func initStaticRoutes(root *gin.RouterGroup) {
}
// 优先尝试读取 .gz 文件
if gzFile, err := staticFS.Open(gzPath); err == nil {
if gzFile, err := openFileWithWebui(gzPath); err == nil {
defer gzFile.Close()
ctx.Header("Content-Type", contentType)
@@ -76,7 +102,7 @@ func initStaticRoutes(root *gin.RouterGroup) {
}
// 如果没有 .gz,流式读取原文件
if file, err := staticFS.Open(fullPath); err == nil {
if file, err := openFileWithWebui(fullPath); err == nil {
defer file.Close()
ctx.Header("Content-Type", contentType)
ctx.Status(http.StatusOK)
@@ -133,14 +159,9 @@ func initPWARoutes(root *gin.RouterGroup) {
}
func handleManifest(ctx *gin.Context) {
staticFS := static.GetFS()
if staticFS == nil {
ctx.Status(404)
return
}
// 读取原始 manifest
data, err := fs.ReadFile(staticFS, "manifest.webmanifest")
data, err := readFileWithWebui("manifest.webmanifest")
if err != nil {
ctx.Status(404)
return
@@ -177,11 +198,6 @@ func handleManifest(ctx *gin.Context) {
}
func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) {
staticFS := static.GetFS()
if staticFS == nil {
ctx.Status(404)
return
}
if cache != "" {
ctx.Header("Cache-Control", cache)
@@ -191,7 +207,7 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
// 尝试流式发送压缩版
if gzFile, err := staticFS.Open(filename + ".gz"); err == nil {
if gzFile, err := openFileWithWebui(filename + ".gz"); err == nil {
defer gzFile.Close()
if isGzipSupported {
ctx.Header("Content-Encoding", "gzip")
@@ -207,7 +223,7 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
}
// 尝试流式发送原版
if file, err := staticFS.Open(filename); err == nil {
if file, err := openFileWithWebui(filename); err == nil {
defer file.Close()
ctx.Status(200)
io.Copy(ctx.Writer, file)
@@ -219,20 +235,15 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
// serveSPA 注入配置并返回 index.html 给前端渲染
func serveSPA(ctx *gin.Context, urlPrefix string, status int) {
staticFS := static.GetFS()
if staticFS == nil {
ctx.String(status, "Frontend assets not found.")
return
}
var data []byte
// index.html 较小且需要修改字符串,可以一次性读入内存
if gzFile, err := staticFS.Open("index.html.gz"); err == nil {
if gzFile, err := openFileWithWebui("index.html.gz"); err == nil {
defer gzFile.Close()
gr, _ := gzip.NewReader(gzFile)
data, _ = io.ReadAll(gr)
gr.Close()
} else if file, err := staticFS.Open("index.html"); err == nil {
} else if file, err := openFileWithWebui("index.html"); err == nil {
defer file.Close()
data, _ = io.ReadAll(file)
}
+9 -2
View File
@@ -148,15 +148,22 @@ func (s *SettingsService) Get(section, key string) string {
func (s *SettingsService) Set(section, key, value string) error {
var setting models.Setting
res := database.DB.Where(&models.Setting{Section: section, Key: key}).Limit(1).Find(&setting)
var err error
if res.Error != nil || res.RowsAffected == 0 {
return database.DB.Create(&models.Setting{
err = database.DB.Create(&models.Setting{
ID: utils.GenerateID(),
Section: section,
Key: key,
Value: models.BigText(value),
}).Error
} else {
err = database.DB.Model(&setting).Update("value", models.BigText(value)).Error
}
return database.DB.Model(&setting).Update("value", models.BigText(value)).Error
if err == nil && section == constant.SectionSite {
cache.SetSiteCache(key, value)
}
return err
}
// Delete 删除单个设置
+181
View File
@@ -0,0 +1,181 @@
package services
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/utils"
)
type WebUIService struct {
settingsService *SettingsService
}
func NewWebUIService(settingsService *SettingsService) *WebUIService {
return &WebUIService{
settingsService: settingsService,
}
}
// GetActiveWebUIFS 返回当前激活WebUI的 fs.FS 接口。
// 如果激活的WebUI是 "default" 或者不存在,则返回 nil。
func (s *WebUIService) GetActiveWebUIFS() fs.FS {
activeWebUI := s.settingsService.Get(constant.SectionSite, constant.KeyActiveWebUI)
if activeWebUI == "" || activeWebUI == "default" {
return nil
}
webuiDir := filepath.Join(constant.DataDir, "webuis", activeWebUI)
// 检查是否存在 uimanifest.json 以确认这是一个有效的WebUI目录
if _, err := os.Stat(filepath.Join(webuiDir, "uimanifest.json")); os.IsNotExist(err) {
return nil
}
return os.DirFS(webuiDir)
}
// WebUIManifest 代表 uimanifest.json 中的元数据
type WebUIManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
MinPanelVersion string `json:"min_panel_version"`
}
// GetWebUIs 获取所有可用的WebUI列表
func (s *WebUIService) GetWebUIs() ([]WebUIManifest, error) {
// 默认WebUI总是可用的
webuis := []WebUIManifest{
{
Name: "default",
Version: "builtin",
Author: "Baihu",
Description: "内置默认WebUI",
},
}
records := s.settingsService.GetSection("webui")
for name, val := range records {
var manifest WebUIManifest
if err := json.Unmarshal([]byte(val), &manifest); err == nil {
manifest.Name = name // 强制名称匹配
webuis = append(webuis, manifest)
}
}
return webuis, nil
}
// ExtractWebUI 将 zip 或 tar.gz 压缩包解压到WebUI目录
func (s *WebUIService) ExtractWebUI(zipPath string) (string, error) {
// 1. 创建临时解压目录(放在 DataDir 下避免跨分区移动失败)
baseWebUIDir := filepath.Join(constant.DataDir, "webuis")
if err := os.MkdirAll(baseWebUIDir, 0755); err != nil {
return "", fmt.Errorf("无法创建 WebUI 基础目录: %v", err)
}
tmpDir, err := os.MkdirTemp(baseWebUIDir, "tmp-webui-*")
if err != nil {
return "", fmt.Errorf("无法创建临时解压目录: %v", err)
}
// 确保在出错时清理临时目录
defer os.RemoveAll(tmpDir)
// 2. 根据后缀名选择解压方法
var extractErr error
if strings.HasSuffix(strings.ToLower(zipPath), ".tar.gz") || strings.HasSuffix(strings.ToLower(zipPath), ".tgz") {
extractErr = utils.ExtractTarGz(zipPath, tmpDir)
} else {
extractErr = utils.ExtractZip(zipPath, tmpDir)
}
if extractErr != nil {
return "", fmt.Errorf("解压WebUI包失败: %v", extractErr)
}
// 3. 读取并解析 uimanifest.json
manifestPath := filepath.Join(tmpDir, "uimanifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("webui package must contain a uimanifest.json file")
}
return "", fmt.Errorf("无法读取 uimanifest.json: %v", err)
}
var webuiManifest WebUIManifest
if err := json.Unmarshal(manifestData, &webuiManifest); err != nil {
return "", fmt.Errorf("invalid uimanifest.json format")
}
webuiName := webuiManifest.Name
if webuiName == "" || webuiName == "default" {
return "", fmt.Errorf("invalid webui name in uimanifest.json")
}
// 确保WebUI名称安全,防止目录穿越
webuiName = filepath.Base(filepath.Clean(webuiName))
// 4. 确保压缩包中包含 index.html 入口文件
if _, err := os.Stat(filepath.Join(tmpDir, "index.html")); os.IsNotExist(err) {
return "", fmt.Errorf("webui package must contain an index.html file")
}
// 5. 移动临时目录到最终的目标目录
targetDir := filepath.Join(constant.DataDir, "webuis", webuiName)
// 如果目标目录已存在,先删除旧版本
os.RemoveAll(targetDir)
if err := os.Rename(tmpDir, targetDir); err != nil {
return "", fmt.Errorf("覆盖安装WebUI失败: %v", err)
}
// 6. 将记录保存到 settings 表中
manifestJSON, _ := json.Marshal(webuiManifest)
if err := s.settingsService.Set("webui", webuiName, string(manifestJSON)); err != nil {
// 回滚
os.RemoveAll(targetDir)
return "", fmt.Errorf("保存WebUI记录失败: %v", err)
}
return webuiName, nil
}
// DeleteWebUI 删除自定义WebUI
func (s *WebUIService) DeleteWebUI(name string) error {
if name == "" || name == "default" {
return fmt.Errorf("cannot delete default webui")
}
name = filepath.Base(filepath.Clean(name))
targetDir := filepath.Join(constant.DataDir, "webuis", name)
activeWebUI := s.settingsService.Get(constant.SectionSite, constant.KeyActiveWebUI)
if activeWebUI == name {
return fmt.Errorf("cannot delete currently active webui")
}
if err := os.RemoveAll(targetDir); err != nil {
return err
}
// 从 settings 表中移除记录
return s.settingsService.Delete("webui", name)
}
// SetActiveWebUI 设置当前的活动WebUI
func (s *WebUIService) SetActiveWebUI(name string) error {
if name != "default" {
name = filepath.Base(filepath.Clean(name))
targetDir := filepath.Join(constant.DataDir, "webuis", name)
if _, err := os.Stat(filepath.Join(targetDir, "uimanifest.json")); os.IsNotExist(err) {
return fmt.Errorf("webui %s not found", name)
}
}
return s.settingsService.Set(constant.SectionSite, constant.KeyActiveWebUI, name)
}