feat(app): 添加流量统计功能和服务器配置管理
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m36s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m18s

- 在WebServer中添加TrafficStore存储接口
- 将Web配置从根级别移动到Server.Web子结构下
- 移除Web配置中的BindAddr字段并调整默认值逻辑
- 在前端HomeView中替换模拟流量数据显示真实统计数据
- 添加流量统计API接口(/traffic/stats和/traffic/hourly)
- 实现SQLite数据库流量统计表创建和CRUD操作
- 在Relay包中添加带流量统计的数据转发功能
- 在设置页面添加服务器配置编辑和保存功能
- 创建流量统计处理器和相关数据模型定义
This commit is contained in:
Flik
2026-01-22 19:53:40 +08:00
parent 06dfcfaff3
commit d1058f9e89
14 changed files with 634 additions and 74 deletions

View File

@@ -90,11 +90,11 @@ func main() {
} }
// 启动 Web 控制台 // 启动 Web 控制台
if cfg.Web.Enabled { if cfg.Server.Web.Enabled {
// 强制生成 Web 凭据(如果未配置) // 强制生成 Web 凭据(如果未配置)
if config.GenerateWebCredentials(cfg) { if config.GenerateWebCredentials(cfg) {
log.Printf("[Web] Auto-generated credentials - Username: %s, Password: %s", log.Printf("[Web] Auto-generated credentials - Username: %s, Password: %s",
cfg.Web.Username, cfg.Web.Password) cfg.Server.Web.Username, cfg.Server.Web.Password)
log.Printf("[Web] Please save these credentials and update your config file") log.Printf("[Web] Please save these credentials and update your config file")
// 保存配置以持久化凭据 // 保存配置以持久化凭据
if err := config.SaveServerConfig(*configPath, cfg); err != nil { if err := config.SaveServerConfig(*configPath, cfg); err != nil {
@@ -103,11 +103,11 @@ func main() {
} }
ws := app.NewWebServer(clientStore, server, cfg, *configPath, clientStore) ws := app.NewWebServer(clientStore, server, cfg, *configPath, clientStore)
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort) addr := fmt.Sprintf("%s:%d", cfg.Server.BindAddr, cfg.Server.Web.BindPort)
go func() { go func() {
// 始终使用 JWT 认证 // 始终使用 JWT 认证
err := ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token) err := ws.RunWithJWT(addr, cfg.Server.Web.Username, cfg.Server.Web.Password, cfg.Server.Token)
if err != nil { if err != nil {
log.Printf("[Web] Server error: %v", err) log.Printf("[Web] Server error: %v", err)
} }

View File

@@ -21,16 +21,18 @@ type WebServer struct {
Config *config.ServerConfig Config *config.ServerConfig
ConfigPath string ConfigPath string
JSPluginStore db.JSPluginStore JSPluginStore db.JSPluginStore
TrafficStore db.TrafficStore
} }
// NewWebServer 创建Web服务 // NewWebServer 创建Web服务
func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.ServerConfig, cfgPath string, jsStore db.JSPluginStore) *WebServer { func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.ServerConfig, cfgPath string, store db.Store) *WebServer {
return &WebServer{ return &WebServer{
ClientStore: cs, ClientStore: cs,
Server: srv, Server: srv,
Config: cfg, Config: cfg,
ConfigPath: cfgPath, ConfigPath: cfgPath,
JSPluginStore: jsStore, JSPluginStore: store,
TrafficStore: store,
} }
} }
@@ -109,3 +111,8 @@ func (w *WebServer) SaveConfig() error {
func (w *WebServer) GetJSPluginStore() db.JSPluginStore { func (w *WebServer) GetJSPluginStore() db.JSPluginStore {
return w.JSPluginStore return w.JSPluginStore
} }
// GetTrafficStore 获取流量存储
func (w *WebServer) GetTrafficStore() db.TrafficStore {
return w.TrafficStore
}

View File

@@ -11,7 +11,6 @@ import (
// ServerConfig 服务端配置 // ServerConfig 服务端配置
type ServerConfig struct { type ServerConfig struct {
Server ServerSettings `yaml:"server"` Server ServerSettings `yaml:"server"`
Web WebSettings `yaml:"web"`
PluginStore PluginStoreSettings `yaml:"plugin_store"` PluginStore PluginStoreSettings `yaml:"plugin_store"`
JSPlugins []JSPluginConfig `yaml:"js_plugins,omitempty"` JSPlugins []JSPluginConfig `yaml:"js_plugins,omitempty"`
} }
@@ -44,19 +43,19 @@ func (s *PluginStoreSettings) GetPluginStoreURL() string {
// ServerSettings 服务端设置 // ServerSettings 服务端设置
type ServerSettings struct { type ServerSettings struct {
BindAddr string `yaml:"bind_addr"` BindAddr string `yaml:"bind_addr"`
BindPort int `yaml:"bind_port"` BindPort int `yaml:"bind_port"`
Token string `yaml:"token"` Token string `yaml:"token"`
HeartbeatSec int `yaml:"heartbeat_sec"` HeartbeatSec int `yaml:"heartbeat_sec"`
HeartbeatTimeout int `yaml:"heartbeat_timeout"` HeartbeatTimeout int `yaml:"heartbeat_timeout"`
DBPath string `yaml:"db_path"` DBPath string `yaml:"db_path"`
TLSDisabled bool `yaml:"tls_disabled"` // 默认启用 TLS设置为 true 禁用 TLSDisabled bool `yaml:"tls_disabled"`
Web WebSettings `yaml:"web"`
} }
// WebSettings Web控制台设置 // WebSettings Web控制台设置
type WebSettings struct { type WebSettings struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
BindAddr string `yaml:"bind_addr"`
BindPort int `yaml:"bind_port"` BindPort int `yaml:"bind_port"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
@@ -99,12 +98,9 @@ func setDefaults(cfg *ServerConfig) {
} }
// Web 默认启用 // Web 默认启用
if cfg.Web.BindAddr == "" { if cfg.Server.Web.BindPort == 0 {
cfg.Web.BindAddr = "0.0.0.0" cfg.Server.Web.BindPort = 7500
} cfg.Server.Web.Enabled = true
if cfg.Web.BindPort == 0 {
cfg.Web.BindPort = 7500
cfg.Web.Enabled = true
} }
// Token 未配置时自动生成 32 位 // Token 未配置时自动生成 32 位
@@ -126,11 +122,11 @@ func generateToken(length int) string {
// GenerateWebCredentials 生成 Web 控制台凭据 // GenerateWebCredentials 生成 Web 控制台凭据
func GenerateWebCredentials(cfg *ServerConfig) bool { func GenerateWebCredentials(cfg *ServerConfig) bool {
if cfg.Web.Username == "" { if cfg.Server.Web.Username == "" {
cfg.Web.Username = "admin" cfg.Server.Web.Username = "admin"
} }
if cfg.Web.Password == "" { if cfg.Server.Web.Password == "" {
cfg.Web.Password = generateToken(16) cfg.Server.Web.Password = generateToken(16)
return true // 表示生成了新密码 return true // 表示生成了新密码
} }
return false return false

View File

@@ -76,5 +76,21 @@ type JSPluginStore interface {
type Store interface { type Store interface {
ClientStore ClientStore
JSPluginStore JSPluginStore
TrafficStore
Close() error Close() error
} }
// TrafficRecord 流量记录
type TrafficRecord struct {
Timestamp int64 `json:"timestamp"` // Unix 时间戳(小时级别)
Inbound int64 `json:"inbound"` // 入站流量(字节)
Outbound int64 `json:"outbound"` // 出站流量(字节)
}
// TrafficStore 流量存储接口
type TrafficStore interface {
AddTraffic(inbound, outbound int64) error
GetTotalTraffic() (inbound, outbound int64, err error)
Get24HourTraffic() (inbound, outbound int64, err error)
GetHourlyTraffic(hours int) ([]TrafficRecord, error)
}

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"sync" "sync"
"time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -80,6 +81,33 @@ func (s *SQLiteStore) init() error {
// 迁移:添加 updated_at 列 // 迁移:添加 updated_at 列
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`) s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`)
// 创建流量统计表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS traffic_stats (
hour_ts INTEGER PRIMARY KEY,
inbound INTEGER NOT NULL DEFAULT 0,
outbound INTEGER NOT NULL DEFAULT 0
)
`)
if err != nil {
return err
}
// 创建总流量表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS traffic_total (
id INTEGER PRIMARY KEY CHECK (id = 1),
inbound INTEGER NOT NULL DEFAULT 0,
outbound INTEGER NOT NULL DEFAULT 0
)
`)
if err != nil {
return err
}
// 初始化总流量记录
s.db.Exec(`INSERT OR IGNORE INTO traffic_total (id, inbound, outbound) VALUES (1, 0, 0)`)
return nil return nil
} }
@@ -315,3 +343,82 @@ func (s *SQLiteStore) UpdateJSPluginConfig(name string, config map[string]string
_, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name) _, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name)
return err return err
} }
// ========== 流量统计方法 ==========
// getHourTimestamp 获取当前小时的时间戳
func getHourTimestamp() int64 {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()).Unix()
}
// AddTraffic 添加流量记录
func (s *SQLiteStore) AddTraffic(inbound, outbound int64) error {
s.mu.Lock()
defer s.mu.Unlock()
hourTs := getHourTimestamp()
// 更新小时统计
_, err := s.db.Exec(`
INSERT INTO traffic_stats (hour_ts, inbound, outbound) VALUES (?, ?, ?)
ON CONFLICT(hour_ts) DO UPDATE SET inbound = inbound + ?, outbound = outbound + ?
`, hourTs, inbound, outbound, inbound, outbound)
if err != nil {
return err
}
// 更新总流量
_, err = s.db.Exec(`
UPDATE traffic_total SET inbound = inbound + ?, outbound = outbound + ? WHERE id = 1
`, inbound, outbound)
return err
}
// GetTotalTraffic 获取总流量
func (s *SQLiteStore) GetTotalTraffic() (inbound, outbound int64, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
err = s.db.QueryRow(`SELECT inbound, outbound FROM traffic_total WHERE id = 1`).Scan(&inbound, &outbound)
return
}
// Get24HourTraffic 获取24小时流量
func (s *SQLiteStore) Get24HourTraffic() (inbound, outbound int64, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-24 * time.Hour).Unix()
err = s.db.QueryRow(`
SELECT COALESCE(SUM(inbound), 0), COALESCE(SUM(outbound), 0)
FROM traffic_stats WHERE hour_ts >= ?
`, cutoff).Scan(&inbound, &outbound)
return
}
// GetHourlyTraffic 获取每小时流量记录
func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
rows, err := s.db.Query(`
SELECT hour_ts, inbound, outbound FROM traffic_stats
WHERE hour_ts >= ? ORDER BY hour_ts ASC
`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TrafficRecord
for rows.Next() {
var r TrafficRecord
if err := rows.Scan(&r.Timestamp, &r.Inbound, &r.Outbound); err != nil {
return nil, err
}
records = append(records, r)
}
return records, nil
}

View File

@@ -21,7 +21,6 @@ type ServerConfigPart struct {
// @Description Web 控制台配置 // @Description Web 控制台配置
type WebConfigPart struct { type WebConfigPart struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"` BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username" binding:"omitempty,min=3,max=32"` Username string `json:"username" binding:"omitempty,min=3,max=32"`
Password string `json:"password" binding:"omitempty,min=6,max=64"` Password string `json:"password" binding:"omitempty,min=6,max=64"`
@@ -46,7 +45,6 @@ type ServerConfigInfo struct {
// WebConfigInfo Web 配置信息 // WebConfigInfo Web 配置信息
type WebConfigInfo struct { type WebConfigInfo struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"` BindPort int `json:"bind_port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` // 显示为 **** Password string `json:"password"` // 显示为 ****

View File

@@ -42,10 +42,9 @@ func (h *ConfigHandler) Get(c *gin.Context) {
HeartbeatTimeout: cfg.Server.HeartbeatTimeout, HeartbeatTimeout: cfg.Server.HeartbeatTimeout,
}, },
Web: dto.WebConfigInfo{ Web: dto.WebConfigInfo{
Enabled: cfg.Web.Enabled, Enabled: cfg.Server.Web.Enabled,
BindAddr: cfg.Web.BindAddr, BindPort: cfg.Server.Web.BindPort,
BindPort: cfg.Web.BindPort, Username: cfg.Server.Web.Username,
Username: cfg.Web.Username,
Password: "****", Password: "****",
}, },
} }
@@ -93,15 +92,12 @@ func (h *ConfigHandler) Update(c *gin.Context) {
// 更新 Web 配置 // 更新 Web 配置
if req.Web != nil { if req.Web != nil {
cfg.Web.Enabled = req.Web.Enabled cfg.Server.Web.Enabled = req.Web.Enabled
if req.Web.BindAddr != "" {
cfg.Web.BindAddr = req.Web.BindAddr
}
if req.Web.BindPort > 0 { if req.Web.BindPort > 0 {
cfg.Web.BindPort = req.Web.BindPort cfg.Server.Web.BindPort = req.Web.BindPort
} }
cfg.Web.Username = req.Web.Username cfg.Server.Web.Username = req.Web.Username
cfg.Web.Password = req.Web.Password cfg.Server.Web.Password = req.Web.Password
} }
if err := h.app.SaveConfig(); err != nil { if err := h.app.SaveConfig(); err != nil {

View File

@@ -14,6 +14,7 @@ type AppInterface interface {
GetConfigPath() string GetConfigPath() string
SaveConfig() error SaveConfig() error
GetJSPluginStore() db.JSPluginStore GetJSPluginStore() db.JSPluginStore
GetTrafficStore() db.TrafficStore
} }
// ServerInterface 服务端接口 // ServerInterface 服务端接口

View File

@@ -0,0 +1,76 @@
package handler
import (
"github.com/gin-gonic/gin"
)
// TrafficHandler 流量统计处理器
type TrafficHandler struct {
app AppInterface
}
// NewTrafficHandler 创建流量统计处理器
func NewTrafficHandler(app AppInterface) *TrafficHandler {
return &TrafficHandler{app: app}
}
// GetStats 获取流量统计
// @Summary 获取流量统计
// @Description 获取24小时和总流量统计
// @Tags 流量
// @Produce json
// @Security Bearer
// @Success 200 {object} Response
// @Router /api/traffic/stats [get]
func (h *TrafficHandler) GetStats(c *gin.Context) {
store := h.app.GetTrafficStore()
// 获取24小时流量
in24h, out24h, err := store.Get24HourTraffic()
if err != nil {
InternalError(c, err.Error())
return
}
// 获取总流量
inTotal, outTotal, err := store.GetTotalTraffic()
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"traffic_24h": gin.H{
"inbound": in24h,
"outbound": out24h,
},
"traffic_total": gin.H{
"inbound": inTotal,
"outbound": outTotal,
},
})
}
// GetHourly 获取每小时流量
// @Summary 获取每小时流量
// @Description 获取最近N小时的流量记录
// @Tags 流量
// @Produce json
// @Security Bearer
// @Param hours query int false "小时数" default(24)
// @Success 200 {object} Response
// @Router /api/traffic/hourly [get]
func (h *TrafficHandler) GetHourly(c *gin.Context) {
hours := 24
store := h.app.GetTrafficStore()
records, err := store.GetHourlyTraffic(hours)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"records": records,
})
}

View File

@@ -110,6 +110,11 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
logHandler := handler.NewLogHandler(app) logHandler := handler.NewLogHandler(app)
api.GET("/client/:id/logs", logHandler.StreamLogs) api.GET("/client/:id/logs", logHandler.StreamLogs)
// 流量统计
trafficHandler := handler.NewTrafficHandler(app)
api.GET("/traffic/stats", trafficHandler.GetStats)
api.GET("/traffic/hourly", trafficHandler.GetHourly)
// 插件 API 代理 (通过 Web API 访问客户端插件) // 插件 API 代理 (通过 Web API 访问客户端插件)
pluginAPIHandler := handler.NewPluginAPIHandler(app) pluginAPIHandler := handler.NewPluginAPIHandler(app)
api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest) api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest)

View File

@@ -4,10 +4,17 @@ import (
"io" "io"
"net" "net"
"sync" "sync"
"sync/atomic"
) )
const bufferSize = 32 * 1024 const bufferSize = 32 * 1024
// TrafficStats 流量统计
type TrafficStats struct {
Inbound int64
Outbound int64
}
// Relay 双向数据转发 // Relay 双向数据转发
func Relay(c1, c2 net.Conn) { func Relay(c1, c2 net.Conn) {
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -17,7 +24,6 @@ func Relay(c1, c2 net.Conn) {
defer wg.Done() defer wg.Done()
buf := make([]byte, bufferSize) buf := make([]byte, bufferSize)
_, _ = io.CopyBuffer(dst, src, buf) _, _ = io.CopyBuffer(dst, src, buf)
// 关闭写端,通知对方数据传输完成
if tc, ok := dst.(*net.TCPConn); ok { if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite() tc.CloseWrite()
} }
@@ -27,3 +33,36 @@ func Relay(c1, c2 net.Conn) {
go copyConn(c2, c1) go copyConn(c2, c1)
wg.Wait() wg.Wait()
} }
// RelayWithStats 带流量统计的双向数据转发
func RelayWithStats(c1, c2 net.Conn, onStats func(in, out int64)) {
var wg sync.WaitGroup
var inbound, outbound int64
wg.Add(2)
copyWithCount := func(dst, src net.Conn, counter *int64) {
defer wg.Done()
buf := make([]byte, bufferSize)
for {
n, err := src.Read(buf)
if n > 0 {
atomic.AddInt64(counter, int64(n))
dst.Write(buf[:n])
}
if err != nil {
break
}
}
if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite()
}
}
go copyWithCount(c1, c2, &inbound)
go copyWithCount(c2, c1, &outbound)
wg.Wait()
if onStats != nil {
onStats(inbound, outbound)
}
}

View File

@@ -180,3 +180,47 @@ export const createLogStream = (
return eventSource return eventSource
} }
// 流量统计
export interface TrafficStats {
traffic_24h: { inbound: number; outbound: number }
traffic_total: { inbound: number; outbound: number }
}
export interface TrafficRecord {
timestamp: number
inbound: number
outbound: number
}
export const getTrafficStats = () => get<TrafficStats>('/traffic/stats')
export const getTrafficHourly = () => get<{ records: TrafficRecord[] }>('/traffic/hourly')
// 服务器配置
export interface ServerConfigInfo {
bind_addr: string
bind_port: number
token: string
heartbeat_sec: number
heartbeat_timeout: number
}
export interface WebConfigInfo {
enabled: boolean
bind_port: number
username: string
password: string
}
export interface ServerConfigResponse {
server: ServerConfigInfo
web: WebConfigInfo
}
export interface UpdateServerConfigRequest {
server?: Partial<ServerConfigInfo>
web?: Partial<WebConfigInfo>
}
export const getServerConfig = () => get<ServerConfigResponse>('/config')
export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config)

View File

@@ -1,33 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { getClients } from '../api' import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api'
import type { ClientStatus } from '../types' import type { ClientStatus } from '../types'
const clients = ref<ClientStatus[]>([]) const clients = ref<ClientStatus[]>([])
// Mock data for traffic (API not implemented yet) // 流量统计数据
const trafficStats = ref({ const traffic24h = ref({ inbound: 0, outbound: 0 })
inbound: 0, const trafficTotal = ref({ inbound: 0, outbound: 0 })
outbound: 0, const trafficHistory = ref<TrafficRecord[]>([])
inboundUnit: 'GB',
outboundUnit: 'GB'
})
// Mock 24h traffic data for chart // 格式化字节数
const trafficHistory = ref<Array<{ hour: string; inbound: number; outbound: number }>>([]) const formatBytes = (bytes: number): { value: string; unit: string } => {
if (bytes === 0) return { value: '0', unit: 'B' }
const generateMockTrafficData = () => { const k = 1024
const data = [] const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB']
const now = new Date() const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
for (let i = 23; i >= 0; i--) { return {
const hour = new Date(now.getTime() - i * 60 * 60 * 1000) value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(),
data.push({ unit: sizes[i] as string
hour: hour.getHours().toString().padStart(2, '0') + ':00', }
inbound: Math.random() * 100, }
outbound: Math.random() * 80
}) // 加载流量统计
const loadTrafficStats = async () => {
try {
const { data } = await getTrafficStats()
traffic24h.value = data.traffic_24h
trafficTotal.value = data.traffic_total
} catch (e) {
console.error('Failed to load traffic stats', e)
}
}
// 加载每小时流量
const loadTrafficHourly = async () => {
try {
const { data } = await getTrafficHourly()
trafficHistory.value = data.records || []
} catch (e) {
console.error('Failed to load hourly traffic', e)
} }
trafficHistory.value = data
} }
const loadClients = async () => { const loadClients = async () => {
@@ -47,6 +60,12 @@ const totalRules = computed(() => {
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0) return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
}) })
// 格式化后的流量统计
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
// Chart helpers // Chart helpers
const maxTraffic = computed(() => { const maxTraffic = computed(() => {
const max = Math.max( const max = Math.max(
@@ -59,9 +78,16 @@ const getBarHeight = (value: number) => {
return (value / maxTraffic.value) * 100 return (value / maxTraffic.value) * 100
} }
// 格式化时间戳为小时
const formatHour = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.getHours().toString().padStart(2, '0') + ':00'
}
onMounted(() => { onMounted(() => {
loadClients() loadClients()
generateMockTrafficData() loadTrafficStats()
loadTrafficHourly()
}) })
</script> </script>
@@ -94,9 +120,9 @@ onMounted(() => {
</svg> </svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<span class="stat-label">出站流量</span> <span class="stat-label">24h出站</span>
<span class="stat-value">{{ trafficStats.outbound.toFixed(2) }}</span> <span class="stat-value">{{ formatted24hOutbound.value }}</span>
<span class="stat-unit">{{ trafficStats.outboundUnit }}</span> <span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
</div> </div>
</div> </div>
@@ -108,9 +134,37 @@ onMounted(() => {
</svg> </svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<span class="stat-label">入站流量</span> <span class="stat-label">24h入站</span>
<span class="stat-value">{{ trafficStats.inbound.toFixed(2) }}</span> <span class="stat-value">{{ formatted24hInbound.value }}</span>
<span class="stat-unit">{{ trafficStats.inboundUnit }}</span> <span class="stat-unit">{{ formatted24hInbound.unit }}</span>
</div>
</div>
<!-- Total Outbound Traffic -->
<div class="stat-card glass-stat">
<div class="stat-icon total-out">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12" />
</svg>
</div>
<div class="stat-content">
<span class="stat-label">总出站</span>
<span class="stat-value">{{ formattedTotalOutbound.value }}</span>
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
</div>
</div>
<!-- Total Inbound Traffic -->
<div class="stat-card glass-stat">
<div class="stat-icon total-in">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" />
</svg>
</div>
<div class="stat-content">
<span class="stat-label">总入站</span>
<span class="stat-value">{{ formattedTotalInbound.value }}</span>
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
</div> </div>
</div> </div>
@@ -168,12 +222,12 @@ onMounted(() => {
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div> <div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div> <div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
</div> </div>
<span class="bar-label">{{ data.hour }}</span> <span class="bar-label">{{ formatHour(data.timestamp) }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="chart-hint"> <div class="chart-hint" v-if="trafficHistory.length === 0">
<span>流量统计功能开发中当前显示模拟数据</span> <span>暂无流量数据</span>
</div> </div>
</div> </div>
</div> </div>
@@ -268,7 +322,7 @@ onMounted(() => {
/* Stats Grid */ /* Stats Grid */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 20px; gap: 20px;
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -338,6 +392,16 @@ onMounted(() => {
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.4); box-shadow: 0 4px 16px rgba(245, 158, 11, 0.4);
} }
.stat-icon.total-out {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
box-shadow: 0 4px 16px rgba(2, 132, 199, 0.4);
}
.stat-icon.total-in {
background: linear-gradient(135deg, #c084fc 0%, #9333ea 100%);
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.4);
}
.stat-icon svg { .stat-icon svg {
color: white; color: white;
} }

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5' import { CloudDownloadOutline, RefreshOutline, ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
import GlassTag from '../components/GlassTag.vue' import GlassTag from '../components/GlassTag.vue'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { import {
getVersionInfo, checkServerUpdate, applyServerUpdate, getVersionInfo, checkServerUpdate, applyServerUpdate,
type UpdateInfo, type VersionInfo getServerConfig, updateServerConfig,
type UpdateInfo, type VersionInfo, type ServerConfigResponse
} from '../api' } from '../api'
const message = useToast() const message = useToast()
@@ -18,6 +19,20 @@ const loading = ref(true)
const checkingServer = ref(false) const checkingServer = ref(false)
const updatingServer = ref(false) const updatingServer = ref(false)
// 服务器配置
const serverConfig = ref<ServerConfigResponse | null>(null)
const configLoading = ref(false)
const savingConfig = ref(false)
// 配置表单
const configForm = ref({
bind_addr: '',
heartbeat_sec: 30,
heartbeat_timeout: 90,
web_username: '',
web_password: ''
})
const loadVersionInfo = async () => { const loadVersionInfo = async () => {
try { try {
const { data } = await getVersionInfo() const { data } = await getVersionInfo()
@@ -29,6 +44,53 @@ const loadVersionInfo = async () => {
} }
} }
const loadServerConfig = async () => {
configLoading.value = true
try {
const { data } = await getServerConfig()
serverConfig.value = data
// 填充表单
configForm.value = {
bind_addr: data.server.bind_addr,
heartbeat_sec: data.server.heartbeat_sec,
heartbeat_timeout: data.server.heartbeat_timeout,
web_username: data.web.username,
web_password: ''
}
} catch (e) {
console.error('Failed to load server config', e)
} finally {
configLoading.value = false
}
}
const handleSaveConfig = async () => {
savingConfig.value = true
try {
const updateReq: any = {
server: {
bind_addr: configForm.value.bind_addr,
heartbeat_sec: configForm.value.heartbeat_sec,
heartbeat_timeout: configForm.value.heartbeat_timeout
},
web: {
username: configForm.value.web_username
}
}
// 只有填写了密码才更新
if (configForm.value.web_password) {
updateReq.web.password = configForm.value.web_password
}
await updateServerConfig(updateReq)
message.success('配置已保存,部分配置需要重启服务后生效')
configForm.value.web_password = ''
} catch (e: any) {
message.error(e.response?.data || '保存配置失败')
} finally {
savingConfig.value = false
}
}
const handleCheckServerUpdate = async () => { const handleCheckServerUpdate = async () => {
checkingServer.value = true checkingServer.value = true
try { try {
@@ -83,6 +145,7 @@ const formatBytes = (bytes: number): string => {
onMounted(() => { onMounted(() => {
loadVersionInfo() loadVersionInfo()
loadServerConfig()
}) })
</script> </script>
@@ -140,6 +203,87 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Server Config Card -->
<div class="glass-card">
<div class="card-header">
<h3>服务器配置</h3>
<SettingsOutline class="header-icon" />
</div>
<div class="card-body">
<div v-if="configLoading" class="loading-state">加载中...</div>
<div v-else-if="serverConfig" class="config-form">
<div class="form-group">
<label class="form-label">服务器地址</label>
<input
v-model="configForm.bind_addr"
type="text"
class="glass-input"
placeholder="0.0.0.0"
/>
<span class="form-hint">服务器监听地址修改后需重启生效</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">心跳间隔 ()</label>
<input
v-model.number="configForm.heartbeat_sec"
type="number"
class="glass-input"
min="1"
max="300"
/>
</div>
<div class="form-group">
<label class="form-label">心跳超时 ()</label>
<input
v-model.number="configForm.heartbeat_timeout"
type="number"
class="glass-input"
min="1"
max="600"
/>
</div>
</div>
<div class="form-divider"></div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Web 用户名</label>
<input
v-model="configForm.web_username"
type="text"
class="glass-input"
placeholder="admin"
/>
</div>
<div class="form-group">
<label class="form-label">Web 密码</label>
<input
v-model="configForm.web_password"
type="password"
class="glass-input"
placeholder="留空则不修改"
/>
</div>
</div>
<div class="form-actions">
<button
class="glass-btn primary"
:disabled="savingConfig"
@click="handleSaveConfig"
>
<SaveOutline class="btn-icon" />
保存配置
</button>
</div>
</div>
<div v-else class="empty-state">无法加载配置信息</div>
</div>
</div>
<!-- Server Update Card --> <!-- Server Update Card -->
<div class="glass-card"> <div class="glass-card">
<div class="card-header"> <div class="card-header">
@@ -406,4 +550,71 @@ onMounted(() => {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Config Form */
.config-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.form-hint {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 500px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 8px 0;
}
.form-actions {
margin-top: 8px;
}
/* Glass Input */
.glass-input {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 10px 14px;
color: white;
font-size: 14px;
outline: none;
transition: all 0.2s;
}
.glass-input:focus {
border-color: rgba(96, 165, 250, 0.5);
background: rgba(255, 255, 255, 0.12);
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
</style> </style>