From 323a58e041607f8db0062fab348bba804990a7b5 Mon Sep 17 00:00:00 2001 From: duorameng <2997944583@qq.com> Date: Fri, 29 May 2026 11:49:01 +0800 Subject: [PATCH] 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 --- Makefile | 26 ++ README.md | 2 + cmd/cmd.go | 2 + cmd/webui/webui.go | 132 +++++++++++ docs/.vitepress/config.mts | 1 + docs/guide/changelog.md | 6 + docs/guide/webui.md | 126 ++++++++++ internal/constant/constant.go | 12 +- internal/controllers/webui_controller.go | 93 ++++++++ internal/router/api_routes.go | 11 + internal/router/register.go | 1 + internal/router/router.go | 1 + internal/router/static_routes.go | 63 +++-- internal/services/settings_service.go | 11 +- internal/services/webui_service.go | 181 ++++++++++++++ web/index.html | 2 + web/src/api/index.ts | 30 +++ web/src/views/settings/Settings.vue | 51 +++- web/src/views/settings/WebUISettings.vue | 288 +++++++++++++++++++++++ 19 files changed, 1004 insertions(+), 35 deletions(-) create mode 100644 cmd/webui/webui.go create mode 100644 docs/guide/webui.md create mode 100644 internal/controllers/webui_controller.go create mode 100644 internal/services/webui_service.go create mode 100644 web/src/views/settings/WebUISettings.vue diff --git a/Makefile b/Makefile index be82f19..a22a07a 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,31 @@ all: build build-web: cd web && npm ci && npm run build +pack-webui: + @echo "==> [1/6] 验证参数有效性..." + @if [ -z "$(NAME)" ] || [ -z "$(VERSION)" ] || [ -z "$(AUTHOR)" ] || [ -z "$(DESC)" ]; then \ + echo "Error: Missing required arguments!"; \ + echo "Usage: make pack-webui NAME= VERSION= AUTHOR= DESC="; \ + exit 1; \ + fi + @if [ "$(NAME)" = "default" ]; then \ + echo "Error: WebUI name cannot be 'default' ('default' is reserved for the built-in system identifier)."; \ + exit 1; \ + fi + @echo "==> [2/6] 正在安装前端依赖包 (npm i)..." + cd web && npm i + @echo "==> [3/6] 正在编译构建前端资源文件 (npm run build)..." + cd web && npm run build + @echo "==> [4/6] 正在准备归档输出目录与清理旧包..." + @mkdir -p bin + @rm -f bin/webui-$(NAME)-$(VERSION).tar.gz + @echo "==> [5/6] 正在生成包配置文件 uimanifest.json..." + @echo '{"name": "$(NAME)", "version": "$(VERSION)", "author": "$(AUTHOR)", "description": "$(DESC)"}' > web/dist/uimanifest.json + @echo "==> [6/6] 正在压缩打包为 tar.gz 归档包..." + @sleep 2 + cd web/dist && tar -czf ../../bin/webui-$(NAME)-$(VERSION).tar.gz * + @echo "==> 打包成功!资源包已创建于: bin/webui-$(NAME)-$(VERSION).tar.gz" + # Build the application (requires frontend to be built first) build: @mkdir -p bin @@ -171,6 +196,7 @@ help: @echo " build - Build backend binary (no UI embedded)" @echo " release - Build full release binary (with UI embedded)" @echo " build-web - Build frontend assets only" + @echo " pack-webui - Build and package custom WebUI tar.gz" @echo " build-agent - Build agent packages (tar.gz) for all platforms" @echo " clean - Clean built files" @echo " clean-all - Clean local files and Docker dev environment (including volumes)" diff --git a/README.md b/README.md index d3c150f..4392489 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ ### 最近更新 +**2026.05.29** - **自定义前端框架 (WebUI)**:新增前端定制管理机制,彻底解耦前后端架构。支持上传第三方前端静态资源包 (`.tar.gz` / `.zip`),完全无缝接管并替换内置的系统面板界面,赋能社区实现深度主题化定制。 + **2026.04.16** - **内建脚本助手库 (Built-in SDK)**:新增 Python 与 Node.js 的轻量化助手库,实现脚本内 “零代码配置” 通知投递;配套新增 `baihu builtininstall` 自动化安装命令。 **2026.04.14** - **PWA 与通知渠道增强**:支持 PWA (Progressive Web App) 动态配置,站点标题与图标可由后端实时控制;新增 **VoceChat** 通知渠道支持;增强 **Bark** 推送,支持自建服务器配置。 **2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。 diff --git a/cmd/cmd.go b/cmd/cmd.go index 5f9cf92..b42017c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -6,6 +6,7 @@ import ( "github.com/engigu/baihu-panel/cmd/resetpwd" "github.com/engigu/baihu-panel/cmd/restore" "github.com/engigu/baihu-panel/cmd/task" + "github.com/engigu/baihu-panel/cmd/webui" // "github.com/engigu/baihu-panel/cmd/migrate" ) @@ -19,5 +20,6 @@ var Handlers = map[string]CommandHandler{ "restore": restore.Run, "builtininstall": builtininstall.Run, "task": task.Run, + "webui": webui.Run, // "migrate": migrate.Run, } diff --git a/cmd/webui/webui.go b/cmd/webui/webui.go new file mode 100644 index 0000000..0635a9f --- /dev/null +++ b/cmd/webui/webui.go @@ -0,0 +1,132 @@ +package webui + +import ( + "fmt" + "os" + "strings" + + "github.com/engigu/baihu-panel/cmd/clibase" + "github.com/engigu/baihu-panel/internal/services" +) + +func printMainHelp() { + fmt.Fprintf(os.Stderr, "\n白虎面板 WebUI 命令行管理工具\n\n") + fmt.Fprintf(os.Stderr, "用法:\n") + fmt.Fprintf(os.Stderr, " baihu webui <子命令> [参数]\n\n") + fmt.Fprintf(os.Stderr, "可用子命令:\n") + fmt.Fprintf(os.Stderr, " list 列出当前安装的所有前端资源包\n") + fmt.Fprintf(os.Stderr, " set 设置激活指定的 WebUI\n") + fmt.Fprintf(os.Stderr, " reset 一键回退到系统默认的内置 WebUI\n") + fmt.Fprintf(os.Stderr, " delete 删除指定的 WebUI 资源包\n\n") +} + +func Run(args []string) { + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { + printMainHelp() + return + } + + subCommand := args[0] + subArgs := args[1:] + + switch subCommand { + case "list": + runList(subArgs) + case "set": + runSet(subArgs) + case "reset": + runReset(subArgs) + case "delete": + runDelete(subArgs) + default: + fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand) + printMainHelp() + } +} + +func initServices() *services.WebUIService { + clibase.InitContext(false) + settingsService := services.NewSettingsService() + return services.NewWebUIService(settingsService) +} + +func runList(args []string) { + svc := initServices() + list, err := svc.GetWebUIs() + if err != nil { + fmt.Printf(">> 获取WebUI列表失败: %v\n", err) + return + } + + settingsService := services.NewSettingsService() + activeWebUI := settingsService.Get("site", "active_webui") + if activeWebUI == "" { + activeWebUI = "default" + } + + fmt.Println(strings.Repeat("=", 100)) + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat("名称", 20), + clibase.VisualFormat("版本", 12), + clibase.VisualFormat("作者", 15), + clibase.VisualFormat("状态", 10), + clibase.VisualFormat("描述", 30), + ) + fmt.Println(strings.Repeat("-", 100)) + + for _, w := range list { + status := "-" + if w.Name == activeWebUI { + status = "使用中" + } + + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat(w.Name, 20), + clibase.VisualFormat(w.Version, 12), + clibase.VisualFormat(w.Author, 15), + clibase.VisualFormat(status, 10), + clibase.VisualFormat(w.Description, 30), + ) + } + fmt.Println(strings.Repeat("=", 100)) +} + +func runSet(args []string) { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: baihu webui set \n") + return + } + name := args[0] + svc := initServices() + err := svc.SetActiveWebUI(name) + if err != nil { + fmt.Printf(">> 设置激活WebUI失败: %v\n", err) + return + } + fmt.Printf(">> 成功激活 WebUI: %s\n", name) +} + +func runReset(args []string) { + svc := initServices() + err := svc.SetActiveWebUI("default") + if err != nil { + fmt.Printf(">> 回退默认WebUI失败: %v\n", err) + return + } + fmt.Println(">> 成功回退到内置默认 WebUI") +} + +func runDelete(args []string) { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: baihu webui delete \n") + return + } + name := args[0] + svc := initServices() + err := svc.DeleteWebUI(name) + if err != nil { + fmt.Printf(">> 删除WebUI失败: %v\n", err) + return + } + fmt.Printf(">> 成功删除 WebUI: %s\n", name) +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ee0cbdd..f9982ba 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -52,6 +52,7 @@ export default defineConfig({ text: '部署配置', items: [ { text: '系统配置', link: '/guide/configuration' }, + { text: '前端定制(WebUI)', link: '/guide/webui' }, { text: '反向代理', link: '/guide/nginx' } ] }, diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md index b9ac285..8f3c270 100644 --- a/docs/guide/changelog.md +++ b/docs/guide/changelog.md @@ -4,6 +4,12 @@ ## 最近更新概览 +### 2026.05.29 - 前端定制与 WebUI 插件化 +- **自定义 WebUI 支持 (New)**:新增了前端自定义打包与热切换功能。面板系统彻底解耦前后端静态资源,用户可以在“系统设置 - 前端定制”中上传并管理自定义前端资源包,实现深度的主题替换与定制。 +- **打包工具链整合**:提供了一键构建前端定制包的 `make pack-webui` 快捷命令与规范(自动生成 `uimanifest.json`)。 +- **动态资源托管**:Go 后端引入动态静态资源拦截机制,可无缝接管系统入口与单页应用渲染,同时向下兼容内置面板。 + + ### 2026.04.16 - 内建脚本助手库 (Built-in SDK) - **内建助手库 (Built-in SDK) (New)**:新增 Python 与 Node.js 的轻量级助手库 `baihu`。通过环境自动注入机制实现“零配置”通知投递,开发者无需在脚本中显式配置 TOKEN 或 URL。 - **环境自动初始化**:新增 `baihu builtininstall` 命令行工具,支持一键为 `mise` 管理的所有多语言版本同步安装/刷新内建包依赖。 diff --git a/docs/guide/webui.md b/docs/guide/webui.md new file mode 100644 index 0000000..875c7f2 --- /dev/null +++ b/docs/guide/webui.md @@ -0,0 +1,126 @@ +# 前端定制 (WebUI) + +白虎面板支持完全接管和替换默认系统面板界面。你可以开发自己专属的前端主题,甚至添加自定义的前端交互功能,并打包为独立的 WebUI 资源包上传至系统应用。 + +> [!IMPORTANT] +> **安全与一致性维护声明**: +> 更换前端包后,系统无法自动保障自定义前端的安全性,亦无法确保其与后续更新的后端 API 接口始终保持一致。**自定义前端包的更新、向后兼容维护与漏洞修复需完全由该前端资源提供者(或开发者)负责**。 + +--- + +## 快速使用 + +### 1. 网页端上传与切换 +1. 进入系统后,点击导航栏的 **系统设置**。 +2. 切换到 **前端定制** 面板。 +3. 点击右上角的 **上传前端资源包**,选择你打包好的 `.zip`、`.tar.gz` 或 `.tgz` 格式的前端资源包。 +4. 上传成功后,列表会显示该包的信息(名称、版本、作者、状态等)。 +5. 点击操作栏中的 **启用** 按钮,系统将自动重载并切换至你的自定义前端包。 + +> [!WARNING] +> 自定义前端包若存在 Bug 或打包不完整可能导致界面白屏。如果不慎应用了错误或不兼容的包,请使用下方命令行工具恢复。 + +### 2. 命令行 (CLI) 运维 +当界面因异常白屏无法访问时,可以进入白虎面板容器/服务器终端,使用 `baihu webui` 命令一键管理或恢复: + +- **一键恢复默认内置界面**: + ```bash + baihu webui reset + ``` +- **查看已安装的资源包列表**: + ```bash + baihu webui list + ``` +- **手动切换/启用前端包**: + ```bash + baihu webui set <包名> + ``` +- **删除指定的前端包**: + ```bash + baihu webui delete <包名> + ``` + +--- + +## 开发自定义前端 + +你可以使用 React, Vue, Angular 或任何纯静态 HTML/JS 技术来开发自定义的白虎面板前端。 + +### 1. 核心校验规则 +白虎面板后端提取并启用前端资源时,会执行以下强校验: +1. **压缩包根目录下必须包含 `index.html`**:作为单页应用 (SPA) 的静态入口文件。 +2. **压缩包根目录下必须包含 `uimanifest.json`**:声明该前端包的元数据信息。 + +### 2. 配置文件 `uimanifest.json` 规范 +在前端打包产物的根目录下(与 `index.html` 同级),必须创建一个 `uimanifest.json` 文件。格式示例如下: + +```json +{ + "name": "custom-neon-theme", + "version": "1.0.2", + "author": "YourName", + "description": "白虎面板霓虹暗黑风定制前端主题", + "min_panel_version": "1.0.0" +} +``` + +*注:`name` 字段不能设置为 `"default"`(default 被保留作为内置前端的系统标识)。* + +### 3. API 请求地址与开发环境代理 +在独立开发自定义前端时,需要配置请求与后端的通信地址及代理: + +- **后端默认服务地址与端口**: + 白虎面板后端服务默认运行在端口 `8052` 上,本地调试 API 的基础 URL 通常为: + `http://127.0.0.1:8052/api/v1` + +- **本地开发环境代理配置(以 Vite 为例)**: + 为了避免跨域问题(CORS),推荐在前端开发服务器中设置代理。在 `vite.config.ts` 中配置示例如下: + ```typescript + export default defineConfig({ + server: { + proxy: { + '/api/v1': { + target: 'http://127.0.0.1:8052', // 本地运行的白虎面板后端地址 + changeOrigin: true + } + } + } + }) + ``` + +- **生产环境线上适配(相对路径)**: + 前端包部署生效后,与后端处于同端口同域名下。后端会在返回的 `index.html` 的 `` 中自动注入以下配置变量: + ```html + + ``` + 建议在封装 Axios 或 Fetch 时,直接通过浏览器环境变量拼接相对路径作为 API 地址: + ```typescript + const baseURL = `${window.location.origin}${window.__BASE_URL__ || ''}${window.__API_VERSION__ || '/api/v1'}`; + ``` + +- **接口定义与类型参考**: + 默认系统中已经定义好了所有的后端 API 接口签名、传参格式以及 TS 类型声明。你在二次开发或自定义前端时,可以直接参考项目源码中的前端接口定义文件: `web/src/api/index.ts`。 + +--- + +## 打包前端资源包(现成) + +你可以利用白虎面板项目自带的 `Makefile` 脚本,在现在的前端页面进行修改,将开发好的前端项目快速编译打包成标准的 `.tar.gz` 前端资源包, 自己使用或者分享使用。 + +### 使用 Makefile 打包 + +在项目根目录下,运行以下指令(参数必须填写完整): + +```bash +make pack-webui NAME=neon-theme VERSION=1.0.2 AUTHOR=MyName DESC="霓虹定制主题包" +``` + +该指令会自动执行以下步骤: +1. 进入 `web/` 目录并安装依赖; +2. 编译构建前端静态资源(默认输出到 `web/dist`); +3. 在 `web/dist` 中自动按参数生成校验所需的 `uimanifest.json`; +4. 将该目录下所有文件使用 `tar` 命令进行 gzip 压缩打包; +5. 输出归档文件在项目根目录的 `bin/webui-neon-theme-1.0.2.tar.gz`,此包即可直接在面板中上传安装。 diff --git a/internal/constant/constant.go b/internal/constant/constant.go index f1b84c1..d1f4506 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -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 = ` + +
diff --git a/web/src/api/index.ts b/web/src/api/index.ts index c473d8e..f9bf40d 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -338,9 +338,38 @@ export const api = { }, markAsRead: (data: { id?: string; category?: string }) => request('/app-logs/read', { method: 'POST', body: JSON.stringify(data) }), clear: (category: string) => request('/app-logs/clear', { method: 'POST', body: JSON.stringify({ category }) }) + }, + webui: { + list: () => request('/webui'), + upload: async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const res = await fetch(`${API_BASE_URL}/webui/upload`, { + method: 'POST', + credentials: 'include', + body: formData + }) + const json: ApiResponse<{ message: string, theme: string }> = await res.json() + if (json.code === 401) { + window.location.href = BASE_URL + '/login' + throw new Error('请先登录') + } + if (json.code !== 200) throw new Error(json.msg || '上传失败') + return json.data + }, + setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }), + delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' }) } } +export interface WebUI { + name: string + version: string + author: string + description: string + min_panel_version: string +} + export interface FileNode { name: string path: string @@ -516,6 +545,7 @@ export interface SiteSettings { login_log_max_count?: string scheduler_log_days?: string scheduler_log_max_count?: string + active_webui?: string } export interface SchedulerSettings { diff --git a/web/src/views/settings/Settings.vue b/web/src/views/settings/Settings.vue index 9d08a07..a52ee13 100644 --- a/web/src/views/settings/Settings.vue +++ b/web/src/views/settings/Settings.vue @@ -2,13 +2,17 @@ import { ref } from 'vue' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { UploadCloud, ExternalLink } from 'lucide-vue-next' import PasswordSettings from './PasswordSettings.vue' import SiteSettings from './SiteSettings.vue' import SchedulerSettings from './SchedulerSettings.vue' import BackupSettings from './BackupSettings.vue' import AboutSettings from './AboutSettings.vue' +import WebUISettings from './WebUISettings.vue' const activeTab = ref('password') +const webuiRef = ref(null)