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:
@@ -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)"
|
||||||
|
|||||||
@@ -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 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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` 管理的所有多语言版本同步安装/刷新内建包依赖。
|
||||||
|
|||||||
@@ -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`,此包即可直接在面板中上传安装。
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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已删除"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 删除单个设置
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user