From d1058f9e89ae900a287dfaa917c6bcd4a21518b5 Mon Sep 17 00:00:00 2001 From: Flik Date: Thu, 22 Jan 2026 19:53:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=B7=BB=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD=E5=92=8C=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在WebServer中添加TrafficStore存储接口 - 将Web配置从根级别移动到Server.Web子结构下 - 移除Web配置中的BindAddr字段并调整默认值逻辑 - 在前端HomeView中替换模拟流量数据显示真实统计数据 - 添加流量统计API接口(/traffic/stats和/traffic/hourly) - 实现SQLite数据库流量统计表创建和CRUD操作 - 在Relay包中添加带流量统计的数据转发功能 - 在设置页面添加服务器配置编辑和保存功能 - 创建流量统计处理器和相关数据模型定义 --- cmd/server/main.go | 8 +- internal/server/app/app.go | 11 +- internal/server/config/config.go | 34 ++- internal/server/db/interface.go | 16 ++ internal/server/db/sqlite.go | 107 +++++++++ internal/server/router/dto/config.go | 2 - internal/server/router/handler/config.go | 18 +- internal/server/router/handler/interfaces.go | 1 + internal/server/router/handler/traffic.go | 76 +++++++ internal/server/router/router.go | 5 + pkg/relay/relay.go | 41 +++- web/src/api/index.ts | 44 ++++ web/src/views/HomeView.vue | 130 ++++++++--- web/src/views/SettingsView.vue | 215 ++++++++++++++++++- 14 files changed, 634 insertions(+), 74 deletions(-) create mode 100644 internal/server/router/handler/traffic.go diff --git a/cmd/server/main.go b/cmd/server/main.go index dbf7714..abfb1a9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -90,11 +90,11 @@ func main() { } // 启动 Web 控制台 - if cfg.Web.Enabled { + if cfg.Server.Web.Enabled { // 强制生成 Web 凭据(如果未配置) if config.GenerateWebCredentials(cfg) { 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") // 保存配置以持久化凭据 if err := config.SaveServerConfig(*configPath, cfg); err != nil { @@ -103,11 +103,11 @@ func main() { } 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() { // 始终使用 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 { log.Printf("[Web] Server error: %v", err) } diff --git a/internal/server/app/app.go b/internal/server/app/app.go index 4a4ff13..c428a84 100644 --- a/internal/server/app/app.go +++ b/internal/server/app/app.go @@ -21,16 +21,18 @@ type WebServer struct { Config *config.ServerConfig ConfigPath string JSPluginStore db.JSPluginStore + TrafficStore db.TrafficStore } // 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{ ClientStore: cs, Server: srv, Config: cfg, ConfigPath: cfgPath, - JSPluginStore: jsStore, + JSPluginStore: store, + TrafficStore: store, } } @@ -109,3 +111,8 @@ func (w *WebServer) SaveConfig() error { func (w *WebServer) GetJSPluginStore() db.JSPluginStore { return w.JSPluginStore } + +// GetTrafficStore 获取流量存储 +func (w *WebServer) GetTrafficStore() db.TrafficStore { + return w.TrafficStore +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 42936e6..637f22b 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -11,7 +11,6 @@ import ( // ServerConfig 服务端配置 type ServerConfig struct { Server ServerSettings `yaml:"server"` - Web WebSettings `yaml:"web"` PluginStore PluginStoreSettings `yaml:"plugin_store"` JSPlugins []JSPluginConfig `yaml:"js_plugins,omitempty"` } @@ -44,19 +43,19 @@ func (s *PluginStoreSettings) GetPluginStoreURL() string { // ServerSettings 服务端设置 type ServerSettings struct { - BindAddr string `yaml:"bind_addr"` - BindPort int `yaml:"bind_port"` - Token string `yaml:"token"` - HeartbeatSec int `yaml:"heartbeat_sec"` - HeartbeatTimeout int `yaml:"heartbeat_timeout"` - DBPath string `yaml:"db_path"` - TLSDisabled bool `yaml:"tls_disabled"` // 默认启用 TLS,设置为 true 禁用 + BindAddr string `yaml:"bind_addr"` + BindPort int `yaml:"bind_port"` + Token string `yaml:"token"` + HeartbeatSec int `yaml:"heartbeat_sec"` + HeartbeatTimeout int `yaml:"heartbeat_timeout"` + DBPath string `yaml:"db_path"` + TLSDisabled bool `yaml:"tls_disabled"` + Web WebSettings `yaml:"web"` } // WebSettings Web控制台设置 type WebSettings struct { Enabled bool `yaml:"enabled"` - BindAddr string `yaml:"bind_addr"` BindPort int `yaml:"bind_port"` Username string `yaml:"username"` Password string `yaml:"password"` @@ -99,12 +98,9 @@ func setDefaults(cfg *ServerConfig) { } // Web 默认启用 - if cfg.Web.BindAddr == "" { - cfg.Web.BindAddr = "0.0.0.0" - } - if cfg.Web.BindPort == 0 { - cfg.Web.BindPort = 7500 - cfg.Web.Enabled = true + if cfg.Server.Web.BindPort == 0 { + cfg.Server.Web.BindPort = 7500 + cfg.Server.Web.Enabled = true } // Token 未配置时自动生成 32 位 @@ -126,11 +122,11 @@ func generateToken(length int) string { // GenerateWebCredentials 生成 Web 控制台凭据 func GenerateWebCredentials(cfg *ServerConfig) bool { - if cfg.Web.Username == "" { - cfg.Web.Username = "admin" + if cfg.Server.Web.Username == "" { + cfg.Server.Web.Username = "admin" } - if cfg.Web.Password == "" { - cfg.Web.Password = generateToken(16) + if cfg.Server.Web.Password == "" { + cfg.Server.Web.Password = generateToken(16) return true // 表示生成了新密码 } return false diff --git a/internal/server/db/interface.go b/internal/server/db/interface.go index 16de3cf..601d675 100644 --- a/internal/server/db/interface.go +++ b/internal/server/db/interface.go @@ -76,5 +76,21 @@ type JSPluginStore interface { type Store interface { ClientStore JSPluginStore + TrafficStore 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) +} diff --git a/internal/server/db/sqlite.go b/internal/server/db/sqlite.go index 8958643..eabe82c 100644 --- a/internal/server/db/sqlite.go +++ b/internal/server/db/sqlite.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "sync" + "time" _ "modernc.org/sqlite" @@ -80,6 +81,33 @@ func (s *SQLiteStore) init() error { // 迁移:添加 updated_at 列 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 } @@ -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) 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 +} diff --git a/internal/server/router/dto/config.go b/internal/server/router/dto/config.go index 96956c6..6253c0f 100644 --- a/internal/server/router/dto/config.go +++ b/internal/server/router/dto/config.go @@ -21,7 +21,6 @@ type ServerConfigPart struct { // @Description Web 控制台配置 type WebConfigPart struct { Enabled bool `json:"enabled"` - BindAddr string `json:"bind_addr" binding:"omitempty"` BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"` Username string `json:"username" binding:"omitempty,min=3,max=32"` Password string `json:"password" binding:"omitempty,min=6,max=64"` @@ -46,7 +45,6 @@ type ServerConfigInfo struct { // WebConfigInfo Web 配置信息 type WebConfigInfo struct { Enabled bool `json:"enabled"` - BindAddr string `json:"bind_addr"` BindPort int `json:"bind_port"` Username string `json:"username"` Password string `json:"password"` // 显示为 **** diff --git a/internal/server/router/handler/config.go b/internal/server/router/handler/config.go index a203fc2..91ceb5d 100644 --- a/internal/server/router/handler/config.go +++ b/internal/server/router/handler/config.go @@ -42,10 +42,9 @@ func (h *ConfigHandler) Get(c *gin.Context) { HeartbeatTimeout: cfg.Server.HeartbeatTimeout, }, Web: dto.WebConfigInfo{ - Enabled: cfg.Web.Enabled, - BindAddr: cfg.Web.BindAddr, - BindPort: cfg.Web.BindPort, - Username: cfg.Web.Username, + Enabled: cfg.Server.Web.Enabled, + BindPort: cfg.Server.Web.BindPort, + Username: cfg.Server.Web.Username, Password: "****", }, } @@ -93,15 +92,12 @@ func (h *ConfigHandler) Update(c *gin.Context) { // 更新 Web 配置 if req.Web != nil { - cfg.Web.Enabled = req.Web.Enabled - if req.Web.BindAddr != "" { - cfg.Web.BindAddr = req.Web.BindAddr - } + cfg.Server.Web.Enabled = req.Web.Enabled 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.Web.Password = req.Web.Password + cfg.Server.Web.Username = req.Web.Username + cfg.Server.Web.Password = req.Web.Password } if err := h.app.SaveConfig(); err != nil { diff --git a/internal/server/router/handler/interfaces.go b/internal/server/router/handler/interfaces.go index f511d79..fde81c1 100644 --- a/internal/server/router/handler/interfaces.go +++ b/internal/server/router/handler/interfaces.go @@ -14,6 +14,7 @@ type AppInterface interface { GetConfigPath() string SaveConfig() error GetJSPluginStore() db.JSPluginStore + GetTrafficStore() db.TrafficStore } // ServerInterface 服务端接口 diff --git a/internal/server/router/handler/traffic.go b/internal/server/router/handler/traffic.go new file mode 100644 index 0000000..debd3a7 --- /dev/null +++ b/internal/server/router/handler/traffic.go @@ -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, + }) +} diff --git a/internal/server/router/router.go b/internal/server/router/router.go index d521302..0eb66c3 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -110,6 +110,11 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth, logHandler := handler.NewLogHandler(app) 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 访问客户端插件) pluginAPIHandler := handler.NewPluginAPIHandler(app) api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest) diff --git a/pkg/relay/relay.go b/pkg/relay/relay.go index 1af86b6..75dd533 100644 --- a/pkg/relay/relay.go +++ b/pkg/relay/relay.go @@ -4,10 +4,17 @@ import ( "io" "net" "sync" + "sync/atomic" ) const bufferSize = 32 * 1024 +// TrafficStats 流量统计 +type TrafficStats struct { + Inbound int64 + Outbound int64 +} + // Relay 双向数据转发 func Relay(c1, c2 net.Conn) { var wg sync.WaitGroup @@ -17,7 +24,6 @@ func Relay(c1, c2 net.Conn) { defer wg.Done() buf := make([]byte, bufferSize) _, _ = io.CopyBuffer(dst, src, buf) - // 关闭写端,通知对方数据传输完成 if tc, ok := dst.(*net.TCPConn); ok { tc.CloseWrite() } @@ -27,3 +33,36 @@ func Relay(c1, c2 net.Conn) { go copyConn(c2, c1) 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) + } +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a2e72a6..98eb058 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -180,3 +180,47 @@ export const createLogStream = ( 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('/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 + web?: Partial +} + +export const getServerConfig = () => get('/config') +export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config) diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 118e13b..90f1d11 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,33 +1,46 @@ @@ -94,9 +120,9 @@ onMounted(() => {
- 出站流量 - {{ trafficStats.outbound.toFixed(2) }} - {{ trafficStats.outboundUnit }} + 24h出站 + {{ formatted24hOutbound.value }} + {{ formatted24hOutbound.unit }}
@@ -108,9 +134,37 @@ onMounted(() => {
- 入站流量 - {{ trafficStats.inbound.toFixed(2) }} - {{ trafficStats.inboundUnit }} + 24h入站 + {{ formatted24hInbound.value }} + {{ formatted24hInbound.unit }} +
+ + + +
+
+ + + +
+
+ 总出站 + {{ formattedTotalOutbound.value }} + {{ formattedTotalOutbound.unit }} +
+
+ + +
+
+ + + +
+
+ 总入站 + {{ formattedTotalInbound.value }} + {{ formattedTotalInbound.unit }}
@@ -168,12 +222,12 @@ onMounted(() => {
- {{ data.hour }} + {{ formatHour(data.timestamp) }} -
- 流量统计功能开发中,当前显示模拟数据 +
+ 暂无流量数据
@@ -268,7 +322,7 @@ onMounted(() => { /* Stats Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 32px; } @@ -338,6 +392,16 @@ onMounted(() => { 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 { color: white; } diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 599e8cc..5f96132 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -1,12 +1,13 @@ @@ -140,6 +203,87 @@ onMounted(() => { + +
+
+

服务器配置

+ +
+
+
加载中...
+
+
+ + + 服务器监听地址,修改后需重启生效 +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
无法加载配置信息
+
+
+
@@ -406,4 +550,71 @@ onMounted(() => { width: 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); +}