feat: add url prefix path
This commit is contained in:
@@ -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_
|
||||
```
|
||||
|
||||
</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 配置示例。
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
[server]
|
||||
port = 8052
|
||||
host = 0.0.0.0
|
||||
# URL前缀,例如 /baihu,留空则无前缀
|
||||
# 配置后:前端路径为 /baihu/*,后端API路径为 /baihu/api/v1/*
|
||||
url_prefix =
|
||||
|
||||
[database]
|
||||
type = sqlite
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 }) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
// 扩展 Window 接口,添加后端注入的全局变量
|
||||
declare global {
|
||||
interface Window {
|
||||
__BASE_URL__?: string
|
||||
__API_VERSION__?: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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
@@ -18,5 +18,7 @@ export default defineConfig({
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 支持通过环境变量设置 base URL(开发时测试用)
|
||||
base: process.env.VITE_BASE_URL || '/'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user