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
+26
View File
@@ -22,6 +22,31 @@ all: build
build-web: build-web:
cd web && npm ci && npm run build 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=<name> VERSION=<version> AUTHOR=<author> DESC=<description>"; \
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 the application (requires frontend to be built first)
build: build:
@mkdir -p bin @mkdir -p bin
@@ -171,6 +196,7 @@ help:
@echo " build - Build backend binary (no UI embedded)" @echo " build - Build backend binary (no UI embedded)"
@echo " release - Build full release binary (with UI embedded)" @echo " release - Build full release binary (with UI embedded)"
@echo " build-web - Build frontend assets only" @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 " build-agent - Build agent packages (tar.gz) for all platforms"
@echo " clean - Clean built files" @echo " clean - Clean built files"
@echo " clean-all - Clean local files and Docker dev environment (including volumes)" @echo " clean-all - Clean local files and Docker dev environment (including volumes)"
+2
View File
@@ -16,6 +16,8 @@
### 最近更新 ### 最近更新
**2026.05.29** - **自定义前端框架 (WebUI)**:新增前端定制管理机制,彻底解耦前后端架构。支持上传第三方前端静态资源包 (`.tar.gz` / `.zip`),完全无缝接管并替换内置的系统面板界面,赋能社区实现深度主题化定制。
**2026.04.16** - **内建脚本助手库 (Built-in SDK)**:新增 Python 与 Node.js 的轻量化助手库,实现脚本内 “零代码配置” 通知投递;配套新增 `baihu builtininstall` 自动化安装命令。 **2026.04.16** - **内建脚本助手库 (Built-in SDK)**:新增 Python 与 Node.js 的轻量化助手库,实现脚本内 “零代码配置” 通知投递;配套新增 `baihu builtininstall` 自动化安装命令。
**2026.04.14** - **PWA 与通知渠道增强**:支持 PWA (Progressive Web App) 动态配置,站点标题与图标可由后端实时控制;新增 **VoceChat** 通知渠道支持;增强 **Bark** 推送,支持自建服务器配置。 **2026.04.14** - **PWA 与通知渠道增强**:支持 PWA (Progressive Web App) 动态配置,站点标题与图标可由后端实时控制;新增 **VoceChat** 通知渠道支持;增强 **Bark** 推送,支持自建服务器配置。
**2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。 **2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。
+2
View File
@@ -6,6 +6,7 @@ import (
"github.com/engigu/baihu-panel/cmd/resetpwd" "github.com/engigu/baihu-panel/cmd/resetpwd"
"github.com/engigu/baihu-panel/cmd/restore" "github.com/engigu/baihu-panel/cmd/restore"
"github.com/engigu/baihu-panel/cmd/task" "github.com/engigu/baihu-panel/cmd/task"
"github.com/engigu/baihu-panel/cmd/webui"
// "github.com/engigu/baihu-panel/cmd/migrate" // "github.com/engigu/baihu-panel/cmd/migrate"
) )
@@ -19,5 +20,6 @@ var Handlers = map[string]CommandHandler{
"restore": restore.Run, "restore": restore.Run,
"builtininstall": builtininstall.Run, "builtininstall": builtininstall.Run,
"task": task.Run, "task": task.Run,
"webui": webui.Run,
// "migrate": migrate.Run, // "migrate": migrate.Run,
} }
+132
View File
@@ -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 <name>\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 <name>\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)
}
+1
View File
@@ -52,6 +52,7 @@ export default defineConfig({
text: '部署配置', text: '部署配置',
items: [ items: [
{ text: '系统配置', link: '/guide/configuration' }, { text: '系统配置', link: '/guide/configuration' },
{ text: '前端定制(WebUI)', link: '/guide/webui' },
{ text: '反向代理', link: '/guide/nginx' } { text: '反向代理', link: '/guide/nginx' }
] ]
}, },
+6
View File
@@ -4,6 +4,12 @@
## 最近更新概览 ## 最近更新概览
### 2026.05.29 - 前端定制与 WebUI 插件化
- **自定义 WebUI 支持 (New)**:新增了前端自定义打包与热切换功能。面板系统彻底解耦前后端静态资源,用户可以在“系统设置 - 前端定制”中上传并管理自定义前端资源包,实现深度的主题替换与定制。
- **打包工具链整合**:提供了一键构建前端定制包的 `make pack-webui` 快捷命令与规范(自动生成 `uimanifest.json`)。
- **动态资源托管**:Go 后端引入动态静态资源拦截机制,可无缝接管系统入口与单页应用渲染,同时向下兼容内置面板。
### 2026.04.16 - 内建脚本助手库 (Built-in SDK) ### 2026.04.16 - 内建脚本助手库 (Built-in SDK)
- **内建助手库 (Built-in SDK) (New)**:新增 Python 与 Node.js 的轻量级助手库 `baihu`。通过环境自动注入机制实现“零配置”通知投递,开发者无需在脚本中显式配置 TOKEN 或 URL。 - **内建助手库 (Built-in SDK) (New)**:新增 Python 与 Node.js 的轻量级助手库 `baihu`。通过环境自动注入机制实现“零配置”通知投递,开发者无需在脚本中显式配置 TOKEN 或 URL。
- **环境自动初始化**:新增 `baihu builtininstall` 命令行工具,支持一键为 `mise` 管理的所有多语言版本同步安装/刷新内建包依赖。 - **环境自动初始化**:新增 `baihu builtininstall` 命令行工具,支持一键为 `mise` 管理的所有多语言版本同步安装/刷新内建包依赖。
+126
View File
@@ -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` 的 `<head>` 中自动注入以下配置变量:
```html
<script>
window.__BASE_URL__ = ""; // 部署子路径前缀 (根据实际反代配置)
window.__API_VERSION__ = "/api/v1"; // API 接口版本前缀
</script>
```
建议在封装 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`,此包即可直接在面板中上传安装。
+7 -5
View File
@@ -30,6 +30,7 @@ const (
KeyPageSize = "page_size" KeyPageSize = "page_size"
KeyCookieDays = "cookie_days" KeyCookieDays = "cookie_days"
KeyOpenapiToken = "openapi_token" KeyOpenapiToken = "openapi_token"
KeyActiveWebUI = "active_webui"
// Security Settings Key 常量 // Security Settings Key 常量
KeySecret = "secret" KeySecret = "secret"
@@ -201,11 +202,12 @@ var DefaultIcon = `<svg t="1766107903919" class="icon" viewBox="0 0 1024 1024" v
// DefaultSettings 默认系统设置 // DefaultSettings 默认系统设置
var DefaultSettings = map[string]map[string]string{ var DefaultSettings = map[string]map[string]string{
SectionSite: { SectionSite: {
KeyTitle: "白虎面板", KeyTitle: "白虎面板",
KeySubtitle: "极致轻量、高性能的自动化任务调度平台", KeySubtitle: "极致轻量、高性能的自动化任务调度平台",
KeyIcon: DefaultIcon, KeyIcon: DefaultIcon,
KeyPageSize: "10", KeyPageSize: "10",
KeyCookieDays: "7", KeyCookieDays: "7",
KeyActiveWebUI: "default",
}, },
SectionScheduler: { SectionScheduler: {
KeyWorkerCount: "4", 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) registerNotificationRoutes(adminOnly, c)
registerAppLogRoutes(adminOnly, c) registerAppLogRoutes(adminOnly, c)
registerSystemWSRoutes(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 连接 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(), Notification: controllers.NewNotificationController(),
AppLog: controllers.NewAppLogController(), AppLog: controllers.NewAppLogController(),
SystemWS: controllers.NewSystemWSController(), SystemWS: controllers.NewSystemWSController(),
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
} }
} }
+1
View File
@@ -30,6 +30,7 @@ type Controllers struct {
Notification *controllers.NotificationController Notification *controllers.NotificationController
AppLog *controllers.AppLogController AppLog *controllers.AppLogController
SystemWS *controllers.SystemWSController SystemWS *controllers.SystemWSController
WebUI *controllers.WebUIController
} }
func Setup(c *Controllers) *gin.Engine { 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) { func openFileWithWebui(filename string) (fs.File, error) {
staticFS := static.GetFS() webuiSvc := services.NewWebUIService(services.NewSettingsService())
if staticFS == nil { if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil {
return // 如果启用了定义的前端包,去取定义的路径
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 目录下的资源 // 专门处理 /assets 目录下的资源
root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) { root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) {
fullPath := "assets" + ctx.Param("filepath") fullPath := "assets" + ctx.Param("filepath")
@@ -56,7 +82,7 @@ func initStaticRoutes(root *gin.RouterGroup) {
} }
// 优先尝试读取 .gz 文件 // 优先尝试读取 .gz 文件
if gzFile, err := staticFS.Open(gzPath); err == nil { if gzFile, err := openFileWithWebui(gzPath); err == nil {
defer gzFile.Close() defer gzFile.Close()
ctx.Header("Content-Type", contentType) ctx.Header("Content-Type", contentType)
@@ -76,7 +102,7 @@ func initStaticRoutes(root *gin.RouterGroup) {
} }
// 如果没有 .gz,流式读取原文件 // 如果没有 .gz,流式读取原文件
if file, err := staticFS.Open(fullPath); err == nil { if file, err := openFileWithWebui(fullPath); err == nil {
defer file.Close() defer file.Close()
ctx.Header("Content-Type", contentType) ctx.Header("Content-Type", contentType)
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
@@ -133,14 +159,9 @@ func initPWARoutes(root *gin.RouterGroup) {
} }
func handleManifest(ctx *gin.Context) { func handleManifest(ctx *gin.Context) {
staticFS := static.GetFS()
if staticFS == nil {
ctx.Status(404)
return
}
// 读取原始 manifest // 读取原始 manifest
data, err := fs.ReadFile(staticFS, "manifest.webmanifest") data, err := readFileWithWebui("manifest.webmanifest")
if err != nil { if err != nil {
ctx.Status(404) ctx.Status(404)
return return
@@ -177,11 +198,6 @@ func handleManifest(ctx *gin.Context) {
} }
func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) { func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) {
staticFS := static.GetFS()
if staticFS == nil {
ctx.Status(404)
return
}
if cache != "" { if cache != "" {
ctx.Header("Cache-Control", 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") 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() defer gzFile.Close()
if isGzipSupported { if isGzipSupported {
ctx.Header("Content-Encoding", "gzip") 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() defer file.Close()
ctx.Status(200) ctx.Status(200)
io.Copy(ctx.Writer, file) io.Copy(ctx.Writer, file)
@@ -219,20 +235,15 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
// serveSPA 注入配置并返回 index.html 给前端渲染 // serveSPA 注入配置并返回 index.html 给前端渲染
func serveSPA(ctx *gin.Context, urlPrefix string, status int) { 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 var data []byte
// index.html 较小且需要修改字符串,可以一次性读入内存 // index.html 较小且需要修改字符串,可以一次性读入内存
if gzFile, err := staticFS.Open("index.html.gz"); err == nil { if gzFile, err := openFileWithWebui("index.html.gz"); err == nil {
defer gzFile.Close() defer gzFile.Close()
gr, _ := gzip.NewReader(gzFile) gr, _ := gzip.NewReader(gzFile)
data, _ = io.ReadAll(gr) data, _ = io.ReadAll(gr)
gr.Close() gr.Close()
} else if file, err := staticFS.Open("index.html"); err == nil { } else if file, err := openFileWithWebui("index.html"); err == nil {
defer file.Close() defer file.Close()
data, _ = io.ReadAll(file) 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 { func (s *SettingsService) Set(section, key, value string) error {
var setting models.Setting var setting models.Setting
res := database.DB.Where(&models.Setting{Section: section, Key: key}).Limit(1).Find(&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 { if res.Error != nil || res.RowsAffected == 0 {
return database.DB.Create(&models.Setting{ err = database.DB.Create(&models.Setting{
ID: utils.GenerateID(), ID: utils.GenerateID(),
Section: section, Section: section,
Key: key, Key: key,
Value: models.BigText(value), Value: models.BigText(value),
}).Error }).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 删除单个设置 // 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)
}
+2
View File
@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title> <title></title>
</head> </head>
<!-- <h1>test-pack</h1> -->
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="./src/main.ts"></script> <script type="module" src="./src/main.ts"></script>
+30
View File
@@ -338,9 +338,38 @@ export const api = {
}, },
markAsRead: (data: { id?: string; category?: string }) => request('/app-logs/read', { method: 'POST', body: JSON.stringify(data) }), 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 }) }) clear: (category: string) => request('/app-logs/clear', { method: 'POST', body: JSON.stringify({ category }) })
},
webui: {
list: () => request<WebUI[]>('/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 { export interface FileNode {
name: string name: string
path: string path: string
@@ -516,6 +545,7 @@ export interface SiteSettings {
login_log_max_count?: string login_log_max_count?: string
scheduler_log_days?: string scheduler_log_days?: string
scheduler_log_max_count?: string scheduler_log_max_count?: string
active_webui?: string
} }
export interface SchedulerSettings { export interface SchedulerSettings {
+49 -2
View File
@@ -2,13 +2,17 @@
import { ref } from 'vue' import { ref } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 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 PasswordSettings from './PasswordSettings.vue'
import SiteSettings from './SiteSettings.vue' import SiteSettings from './SiteSettings.vue'
import SchedulerSettings from './SchedulerSettings.vue' import SchedulerSettings from './SchedulerSettings.vue'
import BackupSettings from './BackupSettings.vue' import BackupSettings from './BackupSettings.vue'
import AboutSettings from './AboutSettings.vue' import AboutSettings from './AboutSettings.vue'
import WebUISettings from './WebUISettings.vue'
const activeTab = ref('password') const activeTab = ref('password')
const webuiRef = ref<any>(null)
</script> </script>
<template> <template>
@@ -19,9 +23,10 @@ const activeTab = ref('password')
</div> </div>
<Tabs v-model="activeTab" class="max-w-2xl"> <Tabs v-model="activeTab" class="max-w-2xl">
<TabsList class="w-full grid grid-cols-5 h-auto p-1 bg-muted/50 rounded-lg"> <TabsList class="w-full grid grid-cols-3 sm:grid-cols-6 gap-y-1 sm:gap-y-0 h-auto p-1 bg-muted/50 rounded-lg">
<TabsTrigger value="password" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">密码修改</TabsTrigger> <TabsTrigger value="password" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">密码修改</TabsTrigger>
<TabsTrigger value="site" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">站点设置</TabsTrigger> <TabsTrigger value="site" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">站点设置</TabsTrigger>
<TabsTrigger value="webui" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">前端定制</TabsTrigger>
<TabsTrigger value="scheduler" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">调度设置</TabsTrigger> <TabsTrigger value="scheduler" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">调度设置</TabsTrigger>
<TabsTrigger value="backup" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">备份恢复</TabsTrigger> <TabsTrigger value="backup" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">备份恢复</TabsTrigger>
<TabsTrigger value="about" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">关于</TabsTrigger> <TabsTrigger value="about" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">关于</TabsTrigger>
@@ -43,7 +48,7 @@ const activeTab = ref('password')
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>站点设置</CardTitle> <CardTitle>站点设置</CardTitle>
<CardDescription>配置站点标题图标和系统参数</CardDescription> <CardDescription>配置站点标题图标和系统常规参数</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<SiteSettings /> <SiteSettings />
@@ -51,6 +56,48 @@ const activeTab = ref('password')
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="webui" class="mt-6">
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0 gap-4">
<div class="space-y-1.5 flex-1 min-w-0">
<CardTitle class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span>自定义前端包 (WebUI)</span>
<a href="https://engigu.github.io/baihu-panel/guide/webui" target="_blank" class="flex items-center gap-1 text-xs text-blue-600 hover:underline font-normal shrink-0">
开发文档
<ExternalLink class="w-3 h-3 shrink-0" />
</a>
</CardTitle>
<CardDescription class="line-clamp-2 sm:line-clamp-none">完全接管和替换默认的系统面板界面实现深度定制</CardDescription>
</div>
<div class="shrink-0 mt-0 sm:mt-1">
<Button
@click="webuiRef?.triggerUpload"
:disabled="webuiRef?.uploading"
variant="outline"
size="sm"
class="h-8 px-2 sm:px-3"
title="上传前端资源包"
>
<span v-if="webuiRef?.uploading" class="flex items-center justify-center">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="hidden sm:inline ml-2">上传中...</span>
</span>
<span v-else class="flex items-center">
<UploadCloud class="w-4 h-4" />
<span class="hidden sm:inline ml-2">上传资源包</span>
</span>
</Button>
</div>
</CardHeader>
<CardContent>
<WebUISettings ref="webuiRef" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scheduler" class="mt-6"> <TabsContent value="scheduler" class="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type WebUI } from '@/api'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CheckCircle2, Trash2, MonitorPlay } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
const webuis = ref<WebUI[]>([])
const activeWebUI = ref<string>('default')
const loading = ref(true)
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
// Pagination
const currentPage = ref(1)
const pageSize = 6
const totalPages = computed(() => Math.ceil(webuis.value.length / pageSize))
const paginatedWebuis = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return webuis.value.slice(start, end)
})
const loadData = async () => {
loading.value = true
try {
const [listRes, siteRes] = await Promise.all([
api.webui.list(),
api.settings.getSite()
])
webuis.value = listRes
activeWebUI.value = siteRes.active_webui || 'default'
} catch (err: any) {
toast.error('加载前端包列表失败', { description: err.message })
} finally {
loading.value = false
}
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) return
const file = target.files[0]
if (!file) return
const nameLower = file.name.toLowerCase()
if (!nameLower.endsWith('.zip') && !nameLower.endsWith('.tar.gz') && !nameLower.endsWith('.tgz')) {
toast.error('仅支持上传 .zip 或 .tar.gz 格式的前端包')
return
}
uploading.value = true
try {
await api.webui.upload(file)
toast.success('上传成功', { description: '新的前端包已安装' })
await loadData()
} catch (err: any) {
toast.error('上传失败', { description: err.message })
} finally {
uploading.value = false
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
const triggerUpload = () => {
fileInput.value?.click()
}
const activateWebUI = async (name: string) => {
if (name === activeWebUI.value) return
try {
await api.webui.setActive(name)
toast.success('切换成功', { description: '正在重载前端界面...' })
activeWebUI.value = name
// Reload page after a short delay to apply the new UI
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err: any) {
toast.error('切换失败', { description: err.message })
}
}
const deleteWebUI = async (name: string) => {
if (!confirm(`确定要删除前端包 "${name}" 吗?此操作不可恢复。`)) return
try {
await api.webui.delete(name)
toast.success('删除成功')
// 检查删除后当前页是否为空
if (paginatedWebuis.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
await loadData()
} catch (err: any) {
toast.error('删除失败', { description: err.message })
}
}
defineExpose({
triggerUpload,
uploading
})
onMounted(() => {
loadData()
})
</script>
<template>
<div class="space-y-4">
<!-- Warning Tip -->
<div class="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-2.5 text-[10px] text-yellow-600 dark:text-yellow-400 leading-relaxed mt-2">
<strong>风险提示</strong>自定义前端可能导致界面无法访问如果不慎应用了错误或不兼容的包导致白屏请进入终端执行 <code class="bg-yellow-500/20 px-1 py-0.5 rounded mx-0.5 font-mono">baihu webui reset</code> 一键恢复默认内置界面
</div>
<!-- Hidden file input for uploading -->
<input
type="file"
ref="fileInput"
class="hidden"
accept=".zip,.tar.gz,.tgz"
@change="handleFileUpload"
/>
<!-- WebUI Table / Custom List Layout -->
<div class="rounded-lg border bg-card overflow-hidden">
<!-- 表头 (仅在大屏显示) -->
<div class="hidden sm:flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-32 shrink-0 pl-1">名称</span>
<span class="flex-1 min-w-0">描述</span>
<span class="w-20 shrink-0 text-center">版本</span>
<span class="w-20 shrink-0">作者</span>
<span class="w-20 shrink-0 text-center">状态</span>
<span class="w-24 shrink-0 text-right pr-1">操作</span>
</div>
<!-- 列表内容 -->
<div class="divide-y text-sm">
<template v-if="loading">
<!-- Skeleton rows -->
<div v-for="i in 3" :key="i" class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 px-4 py-3 sm:py-2 hover:bg-muted/30 transition-colors">
<div class="w-full sm:w-32 shrink-0 sm:pl-1 flex justify-between"><div class="h-4 bg-muted rounded w-20 animate-pulse"></div></div>
<div class="w-full sm:flex-1 min-w-0"><div class="h-4 bg-muted rounded w-full animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-10 mx-auto animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-12 animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-12 mx-auto animate-pulse"></div></div>
<div class="w-full sm:w-24 shrink-0 sm:pr-1 flex justify-end"><div class="h-7 bg-muted rounded w-16 animate-pulse"></div></div>
</div>
</template>
<template v-else>
<div v-if="webuis.length === 0" class="text-center py-12 text-muted-foreground text-xs">
暂无前端资源包
</div>
<div v-for="item in paginatedWebuis" :key="item.name"
class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 px-4 py-3 sm:py-1.5 hover:bg-muted/30 transition-colors">
<!-- 名称与移动端状态 -->
<div class="w-full sm:w-32 shrink-0 sm:pl-1 font-medium flex items-center justify-between sm:justify-start gap-2 overflow-hidden">
<div class="flex items-center gap-2 overflow-hidden">
<MonitorPlay class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="truncate" :title="item.name">{{ item.name }}</span>
</div>
<!-- 移动端状态展示 -->
<div class="sm:hidden shrink-0 flex items-center">
<Badge v-if="activeWebUI === item.name" variant="default" class="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20 font-normal py-0 h-5 text-[10px]">
<CheckCircle2 class="w-3 h-3 mr-1" /> 使用中
</Badge>
</div>
</div>
<!-- 描述 -->
<div class="w-full sm:flex-1 min-w-0 text-muted-foreground text-xs truncate" :title="item.description || '无描述'">
{{ item.description || '无描述' }}
</div>
<!-- 移动端版本与作者PC端分成两列 -->
<div class="flex items-center justify-between sm:contents mt-1 sm:mt-0 text-xs text-muted-foreground">
<div class="flex items-center gap-4 sm:contents">
<div class="flex items-center gap-1 sm:w-20 shrink-0 sm:justify-center">
<span class="sm:hidden text-muted-foreground/70">版本:</span>
<Badge variant="outline" class="font-mono text-[9px] px-1 py-0 h-4">v{{ item.version || '1.0' }}</Badge>
</div>
<div class="flex items-center gap-1 sm:w-20 shrink-0 truncate" :title="item.author || 'Unknown'">
<span class="sm:hidden text-muted-foreground/70">作者:</span>
<span class="truncate">{{ item.author || 'Unknown' }}</span>
</div>
</div>
<!-- 移动端的操作按钮放在这里与版本同行 -->
<div class="sm:hidden flex items-center gap-2">
<Button
v-if="activeWebUI !== item.name"
variant="outline"
size="sm"
class="h-6 text-[10px] px-2 py-0"
@click="activateWebUI(item.name)"
>
启用
</Button>
<Button
v-if="item.name !== 'default' && activeWebUI !== item.name"
variant="ghost"
size="icon"
class="h-6 w-6 text-destructive hover:text-destructive hover:bg-destructive/10 shrink-0"
@click="deleteWebUI(item.name)"
>
<Trash2 class="w-3 h-3" />
</Button>
</div>
</div>
<!-- PC端状态 -->
<div class="hidden sm:flex w-20 shrink-0 justify-center">
<Badge v-if="activeWebUI === item.name" variant="default" class="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20 font-normal py-0 h-5 text-[10px]">
<CheckCircle2 class="w-3 h-3 mr-1" /> 使用中
</Badge>
<span v-else class="text-muted-foreground text-xs">-</span>
</div>
<!-- PC端操作 -->
<div class="hidden sm:flex w-24 shrink-0 pr-1 justify-end items-center gap-2">
<Button
v-if="activeWebUI !== item.name"
variant="outline"
size="sm"
class="h-7 text-xs px-2 py-0"
@click="activateWebUI(item.name)"
>
启用
</Button>
<Button
v-else
variant="outline"
disabled
size="sm"
class="h-7 text-xs px-2 py-0 opacity-50"
>
已激活
</Button>
<Button
v-if="item.name !== 'default' && activeWebUI !== item.name"
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10 shrink-0"
@click="deleteWebUI(item.name)"
title="删除此包"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
<div v-else class="w-7 shrink-0"></div>
</div>
</div>
</template>
</div>
</div>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground">
{{ webuis.length }} 个前端包
</p>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</Button>
<div class="text-xs font-medium">
{{ currentPage }} / {{ totalPages }}
</div>
<Button
variant="outline"
size="sm"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</Button>
</div>
</div>
</div>
</template>