feat: add url prefix path
This commit is contained in:
@@ -103,6 +103,12 @@ docker run -d \
|
|||||||
ghcr.io/engigu/baihu:latest
|
ghcr.io/engigu/baihu:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **提示**:如需通过反向代理部署在子路径(如 `/baihu`),添加环境变量:
|
||||||
|
> ```bash
|
||||||
|
> -e BH_SERVER_URL_PREFIX=/baihu
|
||||||
|
> ```
|
||||||
|
> 配置后访问地址为 `http://your-domain.com/baihu`,详见下方「URL 前缀配置」说明。
|
||||||
|
|
||||||
**Docker Compose(SQLite):**
|
**Docker Compose(SQLite):**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -122,6 +128,7 @@ services:
|
|||||||
- BH_DB_TYPE=sqlite
|
- BH_DB_TYPE=sqlite
|
||||||
- BH_DB_PATH=/app/data/baihu.db
|
- BH_DB_PATH=/app/data/baihu.db
|
||||||
- BH_DB_TABLE_PREFIX=baihu_
|
- BH_DB_TABLE_PREFIX=baihu_
|
||||||
|
# - BH_SERVER_URL_PREFIX=/baihu # 可选:配置 URL 前缀用于反向代理
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -152,6 +159,8 @@ docker run -d \
|
|||||||
ghcr.io/engigu/baihu:latest
|
ghcr.io/engigu/baihu:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **提示**:如需配置 URL 前缀,添加 `-e BH_SERVER_URL_PREFIX=/baihu`
|
||||||
|
|
||||||
**Docker Compose(MySQL):**
|
**Docker Compose(MySQL):**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -168,6 +177,7 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- BH_SERVER_PORT=8052
|
- BH_SERVER_PORT=8052
|
||||||
- BH_SERVER_HOST=0.0.0.0
|
- BH_SERVER_HOST=0.0.0.0
|
||||||
|
# - BH_SERVER_URL_PREFIX=/baihu # 可选:配置 URL 前缀
|
||||||
- BH_DB_TYPE=mysql
|
- BH_DB_TYPE=mysql
|
||||||
- BH_DB_HOST=mysql-server
|
- BH_DB_HOST=mysql-server
|
||||||
- BH_DB_PORT=3306
|
- BH_DB_PORT=3306
|
||||||
@@ -229,6 +239,21 @@ services:
|
|||||||
|
|
||||||
首次使用需要复制 `configs/config.example.ini` 为 `configs/config.ini`,然后根据需要修改配置。
|
首次使用需要复制 `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>
|
||||||
|
|
||||||
<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_PORT` | server.port | 服务端口 | 8052 |
|
||||||
| `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 |
|
| `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_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite |
|
||||||
| `BH_DB_HOST` | database.host | 数据库地址 | localhost |
|
| `BH_DB_HOST` | database.host | 数据库地址 | localhost |
|
||||||
| `BH_DB_PORT` | database.port | 数据库端口 | 3306 |
|
| `BH_DB_PORT` | database.port | 数据库端口 | 3306 |
|
||||||
@@ -455,6 +548,48 @@ table_prefix = baihu_
|
|||||||
| `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | baihu_ |
|
| `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | baihu_ |
|
||||||
| `BH_SECRET` | security.secret | JWT 密钥 | 手动指定 |
|
| `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 示例:**
|
||||||
|
|
||||||
参考上方「方式一:环境变量部署」中的 MySQL 配置示例。
|
参考上方「方式一:环境变量部署」中的 MySQL 配置示例。
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
[server]
|
[server]
|
||||||
port = 8052
|
port = 8052
|
||||||
host = 0.0.0.0
|
host = 0.0.0.0
|
||||||
|
# URL前缀,例如 /baihu,留空则无前缀
|
||||||
|
# 配置后:前端路径为 /baihu/*,后端API路径为 /baihu/api/v1/*
|
||||||
|
url_prefix =
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
type = sqlite
|
type = sqlite
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package router
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"baihu/internal/controllers"
|
"baihu/internal/controllers"
|
||||||
"baihu/internal/middleware"
|
"baihu/internal/middleware"
|
||||||
|
"baihu/internal/services"
|
||||||
"baihu/internal/static"
|
"baihu/internal/static"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -47,14 +49,18 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(middleware.GinLogger(), middleware.GinRecovery())
|
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
|
// Serve embedded Vue SPA static files with cache headers
|
||||||
staticFS := static.GetFS()
|
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.Use(cacheControl("public, max-age=31536000, immutable")) // 1 year cache for hashed assets
|
||||||
assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets")))
|
assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets")))
|
||||||
|
|
||||||
// Serve logo.svg with short cache
|
// 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")
|
data, err := static.ReadFile("logo.svg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Status(404)
|
ctx.Status(404)
|
||||||
@@ -66,17 +72,29 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
|
|
||||||
// SPA fallback - serve index.html (no cache for HTML)
|
// SPA fallback - serve index.html (no cache for HTML)
|
||||||
router.NoRoute(func(ctx *gin.Context) {
|
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")
|
data, err := static.ReadFile("index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.String(500, "index.html not found")
|
ctx.String(500, "index.html not found")
|
||||||
return
|
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.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 routes - 添加 /api/v1 版本前缀
|
||||||
api := router.Group("/api")
|
api := router.Group(urlPrefix + "/api/v1")
|
||||||
{
|
{
|
||||||
// Health check (无需认证)
|
// Health check (无需认证)
|
||||||
api.GET("/ping", func(ctx *gin.Context) {
|
api.GET("/ping", func(ctx *gin.Context) {
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `ini:"port"`
|
Port int `ini:"port"`
|
||||||
Host string `ini:"host"`
|
Host string `ini:"host"`
|
||||||
|
URLPrefix string `ini:"url_prefix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -111,6 +112,9 @@ func LoadConfig(path string) (*AppConfig, error) {
|
|||||||
|
|
||||||
// 输出配置信息(隐藏敏感信息)
|
// 输出配置信息(隐藏敏感信息)
|
||||||
logger.Infof("[Config] 服务地址: %s:%d", Config.Server.Host, Config.Server.Port)
|
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",
|
logger.Infof("[Config] 数据库: type=%s, host=%s, port=%d, dbname=%s",
|
||||||
Config.Database.Type, Config.Database.Host, Config.Database.Port, Config.Database.DBName)
|
Config.Database.Type, Config.Database.Host, Config.Database.Port, Config.Database.DBName)
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ func applyEnvOverrides() {
|
|||||||
// Server
|
// Server
|
||||||
getEnvInt("BH_SERVER_PORT", &Config.Server.Port)
|
getEnvInt("BH_SERVER_PORT", &Config.Server.Port)
|
||||||
getEnvStr("BH_SERVER_HOST", &Config.Server.Host)
|
getEnvStr("BH_SERVER_HOST", &Config.Server.Host)
|
||||||
|
getEnvStr("BH_SERVER_URL_PREFIX", &Config.Server.URLPrefix)
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
getEnvStr("BH_DB_TYPE", &Config.Database.Type)
|
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> {
|
interface ApiResponse<T> {
|
||||||
code: number
|
code: number
|
||||||
@@ -7,7 +10,7 @@ interface ApiResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(url: string, options?: RequestInit): Promise<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,
|
...options,
|
||||||
credentials: 'include', // 携带 Cookie
|
credentials: 'include', // 携带 Cookie
|
||||||
headers: {
|
headers: {
|
||||||
@@ -20,7 +23,7 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
// 未登录或登录过期,跳转到登录页
|
// 未登录或登录过期,跳转到登录页
|
||||||
window.location.href = '/login'
|
window.location.href = BASE_URL + '/login'
|
||||||
throw new Error(json.msg || '请先登录')
|
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> {
|
export async function checkAuth(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE_URL}/auth/me`, {
|
const res = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
@@ -128,18 +131,18 @@ export const api = {
|
|||||||
},
|
},
|
||||||
createBackup: () => request('/settings/backup', { method: 'POST' }),
|
createBackup: () => request('/settings/backup', { method: 'POST' }),
|
||||||
getBackupStatus: () => request<{ has_backup: boolean; backup_time: string }>('/settings/backup/status'),
|
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) => {
|
restoreBackup: async (file: File) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
const res = await fetch(`${BASE_URL}/settings/restore`, {
|
const res = await fetch(`${API_BASE_URL}/settings/restore`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
const json: ApiResponse<null> = await res.json()
|
const json: ApiResponse<null> = await res.json()
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
window.location.href = '/login'
|
window.location.href = BASE_URL + '/login'
|
||||||
throw new Error('请先登录')
|
throw new Error('请先登录')
|
||||||
}
|
}
|
||||||
if (json.code !== 200) throw new Error(json.msg || '恢复失败')
|
if (json.code !== 200) throw new Error(json.msg || '恢复失败')
|
||||||
@@ -157,14 +160,14 @@ export const api = {
|
|||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (targetPath) formData.append('path', targetPath)
|
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',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
const json: ApiResponse<null> = await res.json()
|
const json: ApiResponse<null> = await res.json()
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
window.location.href = '/login'
|
window.location.href = BASE_URL + '/login'
|
||||||
throw new Error('请先登录')
|
throw new Error('请先登录')
|
||||||
}
|
}
|
||||||
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
||||||
@@ -180,14 +183,14 @@ export const api = {
|
|||||||
}
|
}
|
||||||
if (targetPath) formData.append('path', targetPath)
|
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',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
const json: ApiResponse<null> = await res.json()
|
const json: ApiResponse<null> = await res.json()
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
window.location.href = '/login'
|
window.location.href = BASE_URL + '/login'
|
||||||
throw new Error('请先登录')
|
throw new Error('请先登录')
|
||||||
}
|
}
|
||||||
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
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) }),
|
request('/agents/' + id, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
delete: (id: number) => request('/agents/' + id, { method: 'DELETE' }),
|
delete: (id: number) => request('/agents/' + id, { method: 'DELETE' }),
|
||||||
forceUpdate: (id: number) => request('/agents/' + id + '/update', { method: 'POST' }),
|
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'),
|
listTokens: () => request<AgentToken[]>('/agents/tokens'),
|
||||||
createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) =>
|
createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) =>
|
||||||
|
|||||||
@@ -134,13 +134,25 @@
|
|||||||
|
|
||||||
@layer base {
|
@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 {
|
body {
|
||||||
@apply bg-background text-foreground antialiased;
|
@apply bg-background text-foreground antialiased;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
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);
|
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);
|
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);
|
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);
|
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);
|
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;
|
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"] {
|
[class*="badge"], [class*="tag"] {
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ function initTerminal(forceConnect = false) {
|
|||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
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)
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const props = defineProps<{
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
:class="cn('px-6', props.class)"
|
:class="cn('px-6 border-0', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<h3
|
<h3
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
:class="cn('leading-none font-semibold', props.class)"
|
:class="cn('leading-none font-semibold border-0', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { checkAuth } from '@/api'
|
import { checkAuth } from '@/api'
|
||||||
|
|
||||||
|
// 获取 base URL(从后端注入的全局变量)
|
||||||
|
const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||||
|
|
||||||
// 缓存认证状态,避免每次路由跳转都请求
|
// 缓存认证状态,避免每次路由跳转都请求
|
||||||
let authChecked = false
|
let authChecked = false
|
||||||
let isAuth = false
|
let isAuth = false
|
||||||
@@ -21,7 +24,7 @@ export function resetAuthCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/login',
|
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">
|
<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 { useRouter } from 'vue-router'
|
||||||
import { ListTodo, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
|
import { ListTodo, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
@@ -77,6 +77,8 @@ async function reloadCharts() {
|
|||||||
pieChart = null
|
pieChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chartsLoaded.value = false
|
||||||
|
|
||||||
// 重新获取数据
|
// 重新获取数据
|
||||||
const [sendStatsData, taskStatsData] = await Promise.all([
|
const [sendStatsData, taskStatsData] = await Promise.all([
|
||||||
api.dashboard.sendStats(chartDays.value),
|
api.dashboard.sendStats(chartDays.value),
|
||||||
@@ -85,22 +87,31 @@ async function reloadCharts() {
|
|||||||
sendStats.value = sendStatsData
|
sendStats.value = sendStatsData
|
||||||
taskStats.value = taskStatsData
|
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()
|
renderLineChart()
|
||||||
renderPieChart()
|
renderPieChart()
|
||||||
|
await nextTick()
|
||||||
chartsLoaded.value = true
|
chartsLoaded.value = true
|
||||||
}, 50)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderLineChart = () => {
|
const renderLineChart = () => {
|
||||||
|
const container = document.querySelector("#stats-chart")
|
||||||
|
if (!container || !container.parentElement) return
|
||||||
|
|
||||||
if (lineChart) {
|
if (lineChart) {
|
||||||
lineChart.destroy()
|
lineChart.destroy()
|
||||||
lineChart = null
|
lineChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.querySelector("#stats-chart")
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
// 清空容器
|
// 清空容器
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
|
|
||||||
@@ -234,14 +245,14 @@ const renderLineChart = () => {
|
|||||||
const renderPieChart = () => {
|
const renderPieChart = () => {
|
||||||
if (taskStats.value.length === 0) return
|
if (taskStats.value.length === 0) return
|
||||||
|
|
||||||
|
const container = document.querySelector("#pie-chart")
|
||||||
|
if (!container || !container.parentElement) return
|
||||||
|
|
||||||
if (pieChart) {
|
if (pieChart) {
|
||||||
pieChart.destroy()
|
pieChart.destroy()
|
||||||
pieChart = null
|
pieChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.querySelector("#pie-chart")
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
// 清空容器
|
// 清空容器
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
|
|
||||||
@@ -416,11 +427,10 @@ onUnmounted(() => {
|
|||||||
<CardTitle class="text-base sm:text-lg">执行统计</CardTitle>
|
<CardTitle class="text-base sm:text-lg">执行统计</CardTitle>
|
||||||
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行情况</CardDescription>
|
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行情况</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pb-8">
|
<CardContent class="pb-8 relative">
|
||||||
<div id="stats-chart" class="w-full h-[300px] sm:h-[300px]">
|
<div id="stats-chart" class="w-full h-[300px] sm:h-[300px]"></div>
|
||||||
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -430,14 +440,13 @@ onUnmounted(() => {
|
|||||||
<CardTitle class="text-base sm:text-lg">任务占比</CardTitle>
|
<CardTitle class="text-base sm:text-lg">任务占比</CardTitle>
|
||||||
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行分布</CardDescription>
|
<CardDescription class="text-xs sm:text-sm">最近{{ chartDays }}天任务执行分布</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pb-8">
|
<CardContent class="pb-8 relative">
|
||||||
<div id="pie-chart" class="w-full h-[300px] sm:h-[300px]">
|
<div id="pie-chart" class="w-full h-[300px] sm:h-[300px]"></div>
|
||||||
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="taskStats.length === 0" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div v-else-if="taskStats.length === 0" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||||
暂无数据
|
暂无数据
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+3
-1
@@ -18,5 +18,7 @@ export default defineConfig({
|
|||||||
ws: true
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
// 支持通过环境变量设置 base URL(开发时测试用)
|
||||||
|
base: process.env.VITE_BASE_URL || '/'
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user