feat: add url prefix path

This commit is contained in:
engigu
2026-01-13 18:18:50 +08:00
parent b07e742550
commit ff6faa229c
15 changed files with 275 additions and 53 deletions
+135
View File
@@ -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 ComposeSQLite):**
```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 ComposeMySQL):**
```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_
```
</details>
<details>
@@ -377,6 +402,73 @@ Message-Push-Nest 提供了便捷的推送代码生成功能:
> 环境变量优先级高于配置文件,两种方式可以混合使用。
<details>
<summary><b>方式四:Nginx 反向代理部署(HTTPS</b></summary>
如果需要通过域名和 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
```
</details>
### 访问面板
@@ -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 配置示例。
+3
View File
@@ -1,6 +1,9 @@
[server]
port = 8052
host = 0.0.0.0
# URL前缀,例如 /baihu,留空则无前缀
# 配置后:前端路径为 /baihu/*,后端API路径为 /baihu/api/v1/*
url_prefix =
[database]
type = sqlite
+23 -5
View File
@@ -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 := `<script>window.__BASE_URL__ = "` + urlPrefix + `"; window.__API_VERSION__ = "/api/v1";</script>`
html = strings.Replace(html, "</head>", configScript+"</head>", 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) {
+7 -2
View File
@@ -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)
+15 -12
View File
@@ -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<T> {
code: number
@@ -7,7 +10,7 @@ interface ApiResponse<T> {
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
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<T>(url: string, options?: RequestInit): Promise<T> {
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<T>(url: string, options?: RequestInit): Promise<T> {
// 检查登录状态(不触发自动跳转)
export async function checkAuth(): Promise<boolean> {
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<null> = 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<null> = 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<null> = 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<AgentToken[]>('/agents/tokens'),
createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) =>
+38 -5
View File
@@ -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);
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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,
)
"
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
:class="cn('px-6 border-0', props.class)"
>
<slot />
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 [&]:border-none [&]:outline-none', props.class)"
>
<slot />
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
:class="cn('leading-none font-semibold border-0', props.class)"
>
<slot />
</h3>
+4 -1
View File
@@ -1,6 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import { checkAuth } from '@/api'
// 获取 base URL(从后端注入的全局变量)
const BASE_URL = (window as any).__BASE_URL__ || ''
// 缓存认证状态,避免每次路由跳转都请求
let authChecked = false
let isAuth = false
@@ -21,7 +24,7 @@ export function resetAuthCache() {
}
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(BASE_URL),
routes: [
{
path: '/login',
+9
View File
@@ -0,0 +1,9 @@
// 扩展 Window 接口,添加后端注入的全局变量
declare global {
interface Window {
__BASE_URL__?: string
__API_VERSION__?: string
}
}
export {}
+31 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ListTodo, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
@@ -77,6 +77,8 @@ async function reloadCharts() {
pieChart = null
}
chartsLoaded.value = false
// 重新获取数据
const [sendStatsData, taskStatsData] = await Promise.all([
api.dashboard.sendStats(chartDays.value),
@@ -85,22 +87,31 @@ async function reloadCharts() {
sendStats.value = sendStatsData
taskStats.value = taskStatsData
setTimeout(() => {
// 等待 Vue 更新 DOM
await nextTick()
await new Promise(resolve => setTimeout(resolve, 100))
// 检查容器是否存在再渲染
const statsChart = document.querySelector("#stats-chart")
const pieChartEl = document.querySelector("#pie-chart")
if (statsChart && pieChartEl && statsChart.parentElement && pieChartEl.parentElement) {
renderLineChart()
renderPieChart()
await nextTick()
chartsLoaded.value = true
}, 50)
}
}
const renderLineChart = () => {
const container = document.querySelector("#stats-chart")
if (!container || !container.parentElement) return
if (lineChart) {
lineChart.destroy()
lineChart = null
}
const container = document.querySelector("#stats-chart")
if (!container) return
// 清空容器
container.innerHTML = ''
@@ -234,14 +245,14 @@ const renderLineChart = () => {
const renderPieChart = () => {
if (taskStats.value.length === 0) return
const container = document.querySelector("#pie-chart")
if (!container || !container.parentElement) return
if (pieChart) {
pieChart.destroy()
pieChart = null
}
const container = document.querySelector("#pie-chart")
if (!container) return
// 清空容器
container.innerHTML = ''
@@ -416,11 +427,10 @@ onUnmounted(() => {
<CardTitle class="text-base sm:text-lg">执行统计</CardTitle>
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行情况</CardDescription>
</CardHeader>
<CardContent class="pb-8">
<div id="stats-chart" class="w-full h-[300px] sm:h-[300px]">
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
加载中...
</div>
<CardContent class="pb-8 relative">
<div id="stats-chart" class="w-full h-[300px] sm:h-[300px]"></div>
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
加载中...
</div>
</CardContent>
</Card>
@@ -430,14 +440,13 @@ onUnmounted(() => {
<CardTitle class="text-base sm:text-lg">任务占比</CardTitle>
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行分布</CardDescription>
</CardHeader>
<CardContent class="pb-8">
<div id="pie-chart" class="w-full h-[300px] sm:h-[300px]">
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
加载中...
</div>
<div v-else-if="taskStats.length === 0" class="h-full flex items-center justify-center text-muted-foreground text-sm">
暂无数据
</div>
<CardContent class="pb-8 relative">
<div id="pie-chart" class="w-full h-[300px] sm:h-[300px]"></div>
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
加载中...
</div>
<div v-else-if="taskStats.length === 0" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
暂无数据
</div>
</CardContent>
</Card>
+3 -1
View File
@@ -18,5 +18,7 @@ export default defineConfig({
ws: true
}
}
}
},
// 支持通过环境变量设置 base URL(开发时测试用)
base: process.env.VITE_BASE_URL || '/'
})