From ff6faa229c946c914cfb4239bb1565706ba15803 Mon Sep 17 00:00:00 2001 From: engigu Date: Tue, 13 Jan 2026 18:18:50 +0800 Subject: [PATCH] feat: add url prefix path --- README.md | 135 +++++++++++++++++++++ configs/config.example.ini | 3 + internal/router/router.go | 28 ++++- internal/services/config_service.go | 9 +- web/src/api/index.ts | 27 +++-- web/src/assets/index.css | 43 ++++++- web/src/components/XTerminal.vue | 4 +- web/src/components/ui/card/Card.vue | 2 +- web/src/components/ui/card/CardContent.vue | 2 +- web/src/components/ui/card/CardHeader.vue | 2 +- web/src/components/ui/card/CardTitle.vue | 2 +- web/src/router/index.ts | 5 +- web/src/types/window.d.ts | 9 ++ web/src/views/dashboard/Dashboard.vue | 53 ++++---- web/vite.config.ts | 4 +- 15 files changed, 275 insertions(+), 53 deletions(-) create mode 100644 web/src/types/window.d.ts diff --git a/README.md b/README.md index 1ce28cc..176d759 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,12 @@ docker run -d \ ghcr.io/engigu/baihu:latest ``` +> **提示**:如需通过反向代理部署在子路径(如 `/baihu`),添加环境变量: +> ```bash +> -e BH_SERVER_URL_PREFIX=/baihu +> ``` +> 配置后访问地址为 `http://your-domain.com/baihu`,详见下方「URL 前缀配置」说明。 + **Docker Compose(SQLite):** ```yaml @@ -122,6 +128,7 @@ services: - BH_DB_TYPE=sqlite - BH_DB_PATH=/app/data/baihu.db - BH_DB_TABLE_PREFIX=baihu_ + # - BH_SERVER_URL_PREFIX=/baihu # 可选:配置 URL 前缀用于反向代理 logging: driver: json-file options: @@ -152,6 +159,8 @@ docker run -d \ ghcr.io/engigu/baihu:latest ``` +> **提示**:如需配置 URL 前缀,添加 `-e BH_SERVER_URL_PREFIX=/baihu` + **Docker Compose(MySQL):** ```yaml @@ -168,6 +177,7 @@ services: - TZ=Asia/Shanghai - BH_SERVER_PORT=8052 - BH_SERVER_HOST=0.0.0.0 + # - BH_SERVER_URL_PREFIX=/baihu # 可选:配置 URL 前缀 - BH_DB_TYPE=mysql - BH_DB_HOST=mysql-server - BH_DB_PORT=3306 @@ -229,6 +239,21 @@ services: 首次使用需要复制 `configs/config.example.ini` 为 `configs/config.ini`,然后根据需要修改配置。 +**配置文件示例(`configs/config.ini`):** + +```ini +[server] +port = 8052 +host = 0.0.0.0 +# 可选:配置 URL 前缀用于反向代理,例如 /baihu +url_prefix = + +[database] +type = sqlite +path = ./data/baihu.db +table_prefix = baihu_ +``` +
@@ -377,6 +402,73 @@ Message-Push-Nest 提供了便捷的推送代码生成功能: > 环境变量优先级高于配置文件,两种方式可以混合使用。 +
+方式四:Nginx 反向代理部署(HTTPS) + +如果需要通过域名和 HTTPS 访问白虎面板,可以使用 Nginx 作为反向代理。 + +**Nginx 配置示例:** + +```nginx +# 在 http 块中添加 WebSocket 升级配置 +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl http2; + server_name example.com; + + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + access_log /var/log/nginx/example.access.log; + error_log /var/log/nginx/example.error.log warn; + + location / { + proxy_pass http://172.17.0.1:8052; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # WebSocket 支持(终端功能需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_buffering off; + proxy_read_timeout 60s; + } +} + +# HTTP 自动跳转 HTTPS(可选) +server { + listen 80; + server_name example.com; + return 301 https://$server_name$request_uri; +} +``` + +**配置说明:** + +1. 将 `example.com` 替换为你的域名 +2. 修改 SSL 证书路径为你的实际路径 +3. `172.17.0.1:8052` 是 Docker 容器的宿主机地址和端口,根据实际情况修改 +4. WebSocket 配置是必需的,否则在线终端功能无法使用 + + +**重载 Nginx 配置:** + +```bash +nginx -t && nginx -s reload +``` + +
+ ### 访问面板 @@ -445,6 +537,7 @@ table_prefix = baihu_ |----------|----------|------|--------| | `BH_SERVER_PORT` | server.port | 服务端口 | 8052 | | `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 | +| `BH_SERVER_URL_PREFIX` | server.url_prefix | URL 前缀,用于反向代理子路径部署 | - | | `BH_DB_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite | | `BH_DB_HOST` | database.host | 数据库地址 | localhost | | `BH_DB_PORT` | database.port | 数据库端口 | 3306 | @@ -455,6 +548,48 @@ table_prefix = baihu_ | `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | baihu_ | | `BH_SECRET` | security.secret | JWT 密钥 | 手动指定 | +### URL 前缀配置 + +如果需要通过反向代理(如 Nginx)将白虎面板部署在子路径下,可以配置 URL 前缀。 + +**配置方式:** + +```bash +# 方式一:配置文件 +[server] +url_prefix = /baihu + +# 方式二:环境变量 +-e BH_SERVER_URL_PREFIX=/baihu +``` + +**配置效果:** + +配置 `url_prefix = /baihu` 后,访问路径变为: + +| 类型 | 路径示例 | +|------|---------| +| 前端页面 | `http://your-domain.com/baihu/` | +| 登录页面 | `http://your-domain.com/baihu/login` | +| 任务管理 | `http://your-domain.com/baihu/tasks` | +| API 接口 | `http://your-domain.com/baihu/api/v1/*` | +| WebSocket | `ws://your-domain.com/baihu/api/v1/terminal/ws` | + +**Nginx 反向代理配置示例:** + +```nginx +location /baihu/ { + proxy_pass http://localhost:8052/baihu/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + **MySQL 示例:** 参考上方「方式一:环境变量部署」中的 MySQL 配置示例。 diff --git a/configs/config.example.ini b/configs/config.example.ini index 5e86122..a81e66e 100644 --- a/configs/config.example.ini +++ b/configs/config.example.ini @@ -1,6 +1,9 @@ [server] port = 8052 host = 0.0.0.0 +# URL前缀,例如 /baihu,留空则无前缀 +# 配置后:前端路径为 /baihu/*,后端API路径为 /baihu/api/v1/* +url_prefix = [database] type = sqlite diff --git a/internal/router/router.go b/internal/router/router.go index accc83f..df791c2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -3,9 +3,11 @@ package router import ( "io/fs" "net/http" + "strings" "baihu/internal/controllers" "baihu/internal/middleware" + "baihu/internal/services" "baihu/internal/static" "github.com/gin-gonic/gin" @@ -47,14 +49,18 @@ func Setup(c *Controllers) *gin.Engine { router := gin.New() router.Use(middleware.GinLogger(), middleware.GinRecovery()) + // 获取 URL 前缀 + cfg := services.GetConfig() + urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/") + // Serve embedded Vue SPA static files with cache headers staticFS := static.GetFS() - assetsGroup := router.Group("/assets") + assetsGroup := router.Group(urlPrefix + "/assets") assetsGroup.Use(cacheControl("public, max-age=31536000, immutable")) // 1 year cache for hashed assets assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets"))) // Serve logo.svg with short cache - router.GET("/logo.svg", func(ctx *gin.Context) { + router.GET(urlPrefix+"/logo.svg", func(ctx *gin.Context) { data, err := static.ReadFile("logo.svg") if err != nil { ctx.Status(404) @@ -66,17 +72,29 @@ func Setup(c *Controllers) *gin.Engine { // SPA fallback - serve index.html (no cache for HTML) router.NoRoute(func(ctx *gin.Context) { + // 只处理带前缀的路径或根路径 + if urlPrefix != "" && !strings.HasPrefix(ctx.Request.URL.Path, urlPrefix) { + ctx.Status(404) + return + } data, err := static.ReadFile("index.html") if err != nil { ctx.String(500, "index.html not found") return } + + // 注入 base URL 配置到 HTML + // 前端使用 urlPrefix,后端 API 使用 urlPrefix + /api/v1 + html := string(data) + configScript := `` + html = strings.Replace(html, "", configScript+"", 1) + ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate") - ctx.Data(200, "text/html; charset=utf-8", data) + ctx.Data(200, "text/html; charset=utf-8", []byte(html)) }) - // API routes - api := router.Group("/api") + // API routes - 添加 /api/v1 版本前缀 + api := router.Group(urlPrefix + "/api/v1") { // Health check (无需认证) api.GET("/ping", func(ctx *gin.Context) { diff --git a/internal/services/config_service.go b/internal/services/config_service.go index f409b0f..d06256b 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -10,8 +10,9 @@ import ( ) type ServerConfig struct { - Port int `ini:"port"` - Host string `ini:"host"` + Port int `ini:"port"` + Host string `ini:"host"` + URLPrefix string `ini:"url_prefix"` } type DatabaseConfig struct { @@ -111,6 +112,9 @@ func LoadConfig(path string) (*AppConfig, error) { // 输出配置信息(隐藏敏感信息) logger.Infof("[Config] 服务地址: %s:%d", Config.Server.Host, Config.Server.Port) + if Config.Server.URLPrefix != "" { + logger.Infof("[Config] URL前缀: %s", Config.Server.URLPrefix) + } logger.Infof("[Config] 数据库: type=%s, host=%s, port=%d, dbname=%s", Config.Database.Type, Config.Database.Host, Config.Database.Port, Config.Database.DBName) @@ -122,6 +126,7 @@ func applyEnvOverrides() { // Server getEnvInt("BH_SERVER_PORT", &Config.Server.Port) getEnvStr("BH_SERVER_HOST", &Config.Server.Host) + getEnvStr("BH_SERVER_URL_PREFIX", &Config.Server.URLPrefix) // Database getEnvStr("BH_DB_TYPE", &Config.Database.Type) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a92f6c2..744bc28 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,4 +1,7 @@ -const BASE_URL = '/api' +// 获取 base URL(从后端注入的全局变量) +const BASE_URL = (window as any).__BASE_URL__ || '' +const API_VERSION = (window as any).__API_VERSION__ || '/api/v1' +const API_BASE_URL = BASE_URL + API_VERSION interface ApiResponse { code: number @@ -7,7 +10,7 @@ interface ApiResponse { } async function request(url: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${url}`, { + const res = await fetch(`${API_BASE_URL}${url}`, { ...options, credentials: 'include', // 携带 Cookie headers: { @@ -20,7 +23,7 @@ async function request(url: string, options?: RequestInit): Promise { if (json.code === 401) { // 未登录或登录过期,跳转到登录页 - window.location.href = '/login' + window.location.href = BASE_URL + '/login' throw new Error(json.msg || '请先登录') } @@ -34,7 +37,7 @@ async function request(url: string, options?: RequestInit): Promise { // 检查登录状态(不触发自动跳转) export async function checkAuth(): Promise { try { - const res = await fetch(`${BASE_URL}/auth/me`, { + const res = await fetch(`${API_BASE_URL}/auth/me`, { credentials: 'include', headers: { 'Content-Type': 'application/json' } }) @@ -128,18 +131,18 @@ export const api = { }, createBackup: () => request('/settings/backup', { method: 'POST' }), getBackupStatus: () => request<{ has_backup: boolean; backup_time: string }>('/settings/backup/status'), - downloadBackup: () => `${BASE_URL}/settings/backup/download`, + downloadBackup: () => `${API_BASE_URL}/settings/backup/download`, restoreBackup: async (file: File) => { const formData = new FormData() formData.append('file', file) - const res = await fetch(`${BASE_URL}/settings/restore`, { + const res = await fetch(`${API_BASE_URL}/settings/restore`, { method: 'POST', credentials: 'include', body: formData }) const json: ApiResponse = await res.json() if (json.code === 401) { - window.location.href = '/login' + window.location.href = BASE_URL + '/login' throw new Error('请先登录') } if (json.code !== 200) throw new Error(json.msg || '恢复失败') @@ -157,14 +160,14 @@ export const api = { formData.append('file', file) if (targetPath) formData.append('path', targetPath) - const res = await fetch(`${BASE_URL}/files/upload`, { + const res = await fetch(`${API_BASE_URL}/files/upload`, { method: 'POST', credentials: 'include', body: formData }) const json: ApiResponse = await res.json() if (json.code === 401) { - window.location.href = '/login' + window.location.href = BASE_URL + '/login' throw new Error('请先登录') } if (json.code !== 200) throw new Error(json.msg || '上传失败') @@ -180,14 +183,14 @@ export const api = { } if (targetPath) formData.append('path', targetPath) - const res = await fetch(`${BASE_URL}/files/uploadfiles`, { + const res = await fetch(`${API_BASE_URL}/files/uploadfiles`, { method: 'POST', credentials: 'include', body: formData }) const json: ApiResponse = await res.json() if (json.code === 401) { - window.location.href = '/login' + window.location.href = BASE_URL + '/login' throw new Error('请先登录') } if (json.code !== 200) throw new Error(json.msg || '上传失败') @@ -215,7 +218,7 @@ export const api = { request('/agents/' + id, { method: 'PUT', body: JSON.stringify(data) }), delete: (id: number) => request('/agents/' + id, { method: 'DELETE' }), forceUpdate: (id: number) => request('/agents/' + id + '/update', { method: 'POST' }), - downloadUrl: (os: string, arch: string) => `${BASE_URL}/agent/download?os=${os}&arch=${arch}`, + downloadUrl: (os: string, arch: string) => `${API_BASE_URL}/agent/download?os=${os}&arch=${arch}`, // 令牌管理 listTokens: () => request('/agents/tokens'), createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) => diff --git a/web/src/assets/index.css b/web/src/assets/index.css index 3126004..dc8e6ab 100644 --- a/web/src/assets/index.css +++ b/web/src/assets/index.css @@ -134,13 +134,25 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply outline-ring/50; + } + *, + ::before, + ::after { + border-color: transparent; + } + .border, + [class*="border-"] { + border-color: var(--border); } body { @apply bg-background text-foreground antialiased; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } } @@ -155,20 +167,20 @@ body { } /* 卡片增强 - 添加微妙阴影 */ -[class*="card"] { +[data-slot="card"] { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05); } -[class*="card"]:hover { +[data-slot="card"]:hover { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.08); } -.dark [class*="card"] { +.dark [data-slot="card"] { box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3); } -.dark [class*="card"]:hover { +.dark [data-slot="card"]:hover { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4); } @@ -230,6 +242,27 @@ pre code { background: transparent; } +/* Windows 小字体优化 */ +.text-xs, .text-sm, [class*="text-xs"], [class*="text-sm"] { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 400; + letter-spacing: 0.01em; +} + +/* 表格和列表中的小字体优化 */ +table, [role="table"] { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 命令和路径文本优化 */ +code, pre, [class*="font-mono"] { + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; + font-weight: 400; +} + /* 徽章和标签增强 */ [class*="badge"], [class*="tag"] { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/web/src/components/XTerminal.vue b/web/src/components/XTerminal.vue index c12204e..368780e 100644 --- a/web/src/components/XTerminal.vue +++ b/web/src/components/XTerminal.vue @@ -128,7 +128,9 @@ function initTerminal(forceConnect = false) { function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws` + const baseUrl = (window as any).__BASE_URL__ || '' + const apiVersion = (window as any).__API_VERSION__ || '/api/v1' + const wsUrl = `${protocol}//${window.location.host}${baseUrl}${apiVersion}/terminal/ws` ws = new WebSocket(wsUrl) diff --git a/web/src/components/ui/card/Card.vue b/web/src/components/ui/card/Card.vue index f5a0707..b1e07f6 100644 --- a/web/src/components/ui/card/Card.vue +++ b/web/src/components/ui/card/Card.vue @@ -12,7 +12,7 @@ const props = defineProps<{ data-slot="card" :class=" cn( - 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', + 'bg-card text-card-foreground flex flex-col rounded-xl border border-border py-6 shadow-sm', props.class, ) " diff --git a/web/src/components/ui/card/CardContent.vue b/web/src/components/ui/card/CardContent.vue index dfbc552..dc71a80 100644 --- a/web/src/components/ui/card/CardContent.vue +++ b/web/src/components/ui/card/CardContent.vue @@ -10,7 +10,7 @@ const props = defineProps<{