优化部分设置逻辑

This commit is contained in:
2026-07-23 19:23:09 +08:00
parent 8c3d7246c2
commit 4cfb684f86
9 changed files with 467 additions and 420 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"log"
"net/http"
"strconv"
@@ -46,7 +47,6 @@ func SetupRouter() *gin.Engine {
// 前端入口
r.GET("/", func(c *gin.Context) {
c.File("./static/index.html")
c.String(200, "frpc-console 后端已启动 🚀")
})
return r
@@ -101,21 +101,29 @@ func updateConfigHandler(c *gin.Context) {
return
}
log.Printf("收到配置更新: %+v", cfg)
if err := UpdateGlobalConfig(&cfg); err != nil {
log.Printf("❌ 更新数据库失败: %v", err) // 新增日志
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败"})
return
}
log.Printf("✅ 数据库更新成功")
// 自动生成配置文件并热加载
if err := GenerateFrpcConfig(); err != nil {
log.Printf("❌ 生成配置文件失败: %v", err) // 新增日志
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()})
return
}
log.Printf("✅ 配置文件生成成功")
if err := ReloadFrpc(); err != nil {
log.Printf("❌ 热加载失败: %v", err) // 新增日志
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()})
return
}
log.Printf("✅ 热加载成功")
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"})
}
+9 -3
View File
@@ -13,7 +13,13 @@ import (
"golang.org/x/crypto/bcrypt"
)
var jwtSecret = []byte("frpc-console-secret-key-2024")
func getJwtSecret() []byte {
secret, err := GetJwtSecret()
if err != nil {
log.Fatal("❌ 获取 JWT 密钥失败:", err)
}
return []byte(secret)
}
type Claims struct {
Username string `json:"username"`
@@ -29,12 +35,12 @@ func GenerateJWT(username string) (string, error) {
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
return token.SignedString(getJwtSecret())
}
func ParseJWT(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
return getJwtSecret(), nil
})
if err != nil {
return nil, err
+94 -17
View File
@@ -1,15 +1,19 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"log"
"sync"
_ "modernc.org/sqlite"
)
var DB *sql.DB
var dbInitOnce sync.Once
// 全局配置表(A表)
// ===== 全局配置表(A表)=====
type GlobalConfig struct {
ID int `json:"id"`
ServerAddr string `json:"serverAddr"`
@@ -24,7 +28,7 @@ type GlobalConfig struct {
PoolCount int `json:"poolCount"`
}
// 隧道表(B表)
// ===== 隧道表(B表)=====
type Proxy struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -35,7 +39,7 @@ type Proxy struct {
Enabled bool `json:"enabled"`
}
// 用户表(C表)
// ===== 用户表(C表)=====
type User struct {
ID int `json:"id"`
Username string `json:"username"`
@@ -43,6 +47,7 @@ type User struct {
CreatedAt string `json:"createdAt"`
}
// ===== 初始化数据库 =====
func InitDB() error {
var err error
DB, err = sql.Open("sqlite", "./frpc-console.db")
@@ -50,7 +55,7 @@ func InitDB() error {
return err
}
// 创建用户表
// 用户表C表)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -64,7 +69,7 @@ func InitDB() error {
return err
}
// 创建全局配置表(只有一行数据
// 全局配置表(A表
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS global_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -85,13 +90,15 @@ func InitDB() error {
return err
}
// 确保全局配置有一行数据
// 确保全局配置有一行数据
var count int
DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count)
if count == 0 {
_, err = DB.Exec(`
INSERT INTO global_config (id, server_addr, server_port, token, log_level, log_max_days, tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count)
VALUES (1, 'frp.whitetop.xyz', 9358, 'Lxh10020328', 'info', 3, 1, 30, 15, 70, 8)
INSERT INTO global_config (
id, server_addr, server_port, token, log_level, log_max_days,
tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count
) VALUES (1, 'frp.whitetop.xyz', 9358, 'Lxh10020328', 'info', 3, 1, 30, 15, 70, 8)
`)
if err != nil {
return err
@@ -99,7 +106,7 @@ func InitDB() error {
log.Println("✅ 全局配置初始化完成")
}
// 创建隧道表
// 隧道表B表)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -117,36 +124,101 @@ func InitDB() error {
return err
}
// 🟢 新增:应用配置表(用于存储 JWT 密钥等全局配置)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// 🟢 确保 JWT 密钥存在
if err := ensureJwtSecret(); err != nil {
return err
}
log.Println("✅ 数据库初始化完成")
return nil
}
// ========== 全局配置 CRUD ==========
// ===== 🟢 新增:JWT 密钥管理 =====
// ensureJwtSecret 检查 app_config 表里是否有 jwt_secret,没有则生成一个
func ensureJwtSecret() error {
var value string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value)
if err == nil && value != "" {
// 密钥已存在
return nil
}
// 生成 32 字节随机密钥
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return err
}
secret := hex.EncodeToString(bytes)
// 存入数据库
_, err = DB.Exec(`
INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?)
`, secret)
if err != nil {
return err
}
log.Printf("✅ JWT 密钥已生成并保存到数据库")
return nil
}
// GetJwtSecret 从数据库读取 JWT 密钥
func GetJwtSecret() (string, error) {
var secret string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret)
if err != nil {
return "", err
}
return secret, nil
}
// ===== 全局配置 CRUD =====
func GetGlobalConfig() (*GlobalConfig, error) {
var cfg GlobalConfig
err := DB.QueryRow(`
SELECT id, server_addr, server_port, token, log_level, log_max_days, tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count
SELECT id, server_addr, server_port, token, log_level, log_max_days,
tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count
FROM global_config WHERE id = 1
`).Scan(&cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token, &cfg.LogLevel, &cfg.LogMaxDays, &cfg.TcpMux, &cfg.TcpMuxKeepalive, &cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount)
`).Scan(
&cfg.ID, &cfg.ServerAddr, &cfg.ServerPort, &cfg.Token,
&cfg.LogLevel, &cfg.LogMaxDays, &cfg.TcpMux, &cfg.TcpMuxKeepalive,
&cfg.HeartbeatInterval, &cfg.HeartbeatTimeout, &cfg.PoolCount,
)
if err != nil {
return nil, err
}
// 🟢 强制开启 tcpMux,忽略数据库中存储的值
cfg.TcpMux = true
return &cfg, nil
}
func UpdateGlobalConfig(cfg *GlobalConfig) error {
// 忽略前端传过来的 TcpMux,强制保持为 true
_, err := DB.Exec(`
UPDATE global_config SET
server_addr = ?, server_port = ?, token = ?, log_level = ?, log_max_days = ?,
tcp_mux = ?, tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?,
tcp_mux = 1, -- 🟢 强制写死为 1
tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, cfg.ServerAddr, cfg.ServerPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays,
cfg.TcpMux, cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount)
cfg.TcpMuxKeepalive, cfg.HeartbeatInterval, cfg.HeartbeatTimeout, cfg.PoolCount)
return err
}
// ========== 隧道 CRUD ==========
// ===== 隧道 CRUD =====
func GetProxies() ([]Proxy, error) {
rows, err := DB.Query(`
SELECT id, name, type, local_ip, local_port, remote_port, enabled
@@ -182,12 +254,17 @@ func GetProxy(id int) (*Proxy, error) {
}
func CreateProxy(p *Proxy) error {
_, err := DB.Exec(`
result, err := DB.Exec(`
INSERT INTO proxies (name, type, local_ip, local_port, remote_port, enabled)
VALUES (?, ?, ?, ?, ?, ?)
`, p.Name, p.Type, p.LocalIP, p.LocalPort, p.RemotePort, p.Enabled)
if err != nil {
return err
}
id, _ := result.LastInsertId()
p.ID = int(id)
return nil
}
func UpdateProxy(p *Proxy) error {
_, err := DB.Exec(`
@@ -204,7 +281,7 @@ func DeleteProxy(id int) error {
return err
}
// ========== 用户 CRUD ==========
// ===== 用户 CRUD =====
func GetUserByUsername(username string) (*User, error) {
var u User
err := DB.QueryRow(`
+78 -69
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"runtime"
"strings"
"sync"
"syscall"
"text/template"
)
@@ -15,10 +16,30 @@ import (
//go:embed bin/*
var embeddedFrpc embed.FS
// 获取当前平台对应的 frpc 路径(从 embed 解压到临时目录)
func getFrpcPath() (string, error) {
var fileName string
//go:embed frpc.tmpl
var frpcTemplateContent string
var (
cachedFrpcPath string
frpcPathMutex sync.Mutex
)
// getFrpcPath 获取当前平台对应的 frpc 路径(优先使用缓存)
func getFrpcPath() (string, error) {
frpcPathMutex.Lock()
defer frpcPathMutex.Unlock()
// 如果缓存有效,直接返回
if cachedFrpcPath != "" {
if _, err := os.Stat(cachedFrpcPath); err == nil {
return cachedFrpcPath, nil
}
// 缓存失效,清空
cachedFrpcPath = ""
}
// 确定文件名
var fileName string
switch {
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
fileName = "frpc_windows_amd64.exe"
@@ -27,20 +48,22 @@ func getFrpcPath() (string, error) {
case runtime.GOOS == "linux" && runtime.GOARCH == "arm64":
fileName = "frpc_linux_arm64"
default:
// 降级:尝试从 PATH 查找 frpc
// 降级:尝试从系统 PATH 查找 frpc
path, err := exec.LookPath("frpc")
if err == nil {
cachedFrpcPath = path
return path, nil
}
return "", fmt.Errorf("不支持的平台: %s/%s,且 PATH 中未找到 frpc", runtime.GOOS, runtime.GOARCH)
return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH)
}
// 从 embed 读取对应文件
// 从 embed 读取
data, err := embeddedFrpc.ReadFile("bin/" + fileName)
if err != nil {
// 如果 embed 中没有,降级到 PATH
// 降级到 PATH
path, lookErr := exec.LookPath("frpc")
if lookErr == nil {
cachedFrpcPath = path
return path, nil
}
return "", fmt.Errorf("未找到 frpc 文件 (embed 和 PATH 均无): %w", err)
@@ -51,39 +74,20 @@ func getFrpcPath() (string, error) {
if runtime.GOOS == "windows" {
tmpPath += ".exe"
}
// 尝试写入(如果文件已存在且正在使用,忽略错误,直接使用已有文件)
if err := os.WriteFile(tmpPath, data, 0755); err != nil {
return "", fmt.Errorf("写入临时文件失败: %w", err)
}
// 如果写入失败,检查是否已有可用的 frpc
if _, statErr := os.Stat(tmpPath); statErr == nil {
cachedFrpcPath = tmpPath
return tmpPath, nil
}
return "", fmt.Errorf("写入临时文件失败: %w", err)
}
// frpc.toml 模板 - 完全复制你的配置文件结构
const frpcTemplate = `serverAddr = "{{.ServerAddr}}"
serverPort = {{.ServerPort}}
[auth]
token = "{{.Token}}"
[log]
to = "./frpc.log"
level = "{{.LogLevel}}"
maxDays = {{.LogMaxDays}}
[transport]
tcpMux = {{.TcpMux}}
tcpMuxKeepaliveInterval = {{.TcpMuxKeepalive}}
heartbeatInterval = {{.HeartbeatInterval}}
heartbeatTimeout = {{.HeartbeatTimeout}}
poolCount = {{.PoolCount}}
{{range .Proxies}}
[[proxies]]
name = "{{.Name}}"
type = "{{.Type}}"
localIP = "{{.LocalIP}}"
localPort = {{.LocalPort}}
remotePort = {{.RemotePort}}
{{end}}`
cachedFrpcPath = tmpPath
return tmpPath, nil
}
// GenerateFrpcConfig 生成 frpc.toml 配置文件
func GenerateFrpcConfig() error {
@@ -112,19 +116,35 @@ func GenerateFrpcConfig() error {
Proxies: activeProxies,
}
tmpl, err := template.New("frpc").Parse(frpcTemplate)
// ===== 核心改动 =====
// 优先使用外部模板文件 frpc.tmpl,如果不存在则使用 embed 中的模板
var tmplContent string
if _, err := os.Stat("frpc.tmpl"); err == nil {
// 外部文件存在,读取它
content, readErr := os.ReadFile("frpc.tmpl")
if readErr == nil {
tmplContent = string(content)
} else {
// 读取失败时回退到 embed
tmplContent = frpcTemplateContent
}
} else {
// 外部文件不存在,使用 embed 中的模板
tmplContent = frpcTemplateContent
}
// ===== 核心改动结束 =====
tmpl, err := template.New("frpc").Parse(tmplContent)
if err != nil {
return fmt.Errorf("解析模板失败: %w", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, data)
if err != nil {
if err := tmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("渲染模板失败: %w", err)
}
err = os.WriteFile("./frpc.toml", buf.Bytes(), 0644)
if err != nil {
if err := os.WriteFile("./frpc.toml", buf.Bytes(), 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
@@ -138,50 +158,47 @@ func StartFrpc() error {
return fmt.Errorf("获取 frpc 路径失败: %w", err)
}
// 检查配置文件是否存在
// 确保配置文件存在
if _, err := os.Stat("./frpc.toml"); os.IsNotExist(err) {
if err := GenerateFrpcConfig(); err != nil {
return fmt.Errorf("生成配置文件失败: %w", err)
}
}
// 检查是否已在运行
// 检查是否已在运行
running, _ := GetFrpcStatus()
if running {
return nil // 已经在运行,不重复启动
return nil
}
cmd := exec.Command(frpcPath, "-c", "./frpc.toml")
// Windows 下需要隐藏窗口(可选)
if runtime.GOOS == "windows" {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
err = cmd.Start()
if err != nil {
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 frpc 失败: %w", err)
}
return nil
}
// ReloadFrpc 热加载 frpc 配置
// ReloadFrpc 热加载 frpc 配置(仅发信号,不重复写入二进制)
func ReloadFrpc() error {
frpcPath, err := getFrpcPath()
if err != nil {
return fmt.Errorf("获取 frpc 路径失败: %w", err)
}
// 检查 frpc 是否在运行
running, err := GetFrpcStatus()
if err != nil {
return fmt.Errorf("检查 frpc 状态失败: %w", err)
}
if !running {
// 没运行就直接启动
return StartFrpc()
}
// 执行 reload
cmd := exec.Command(frpcPath, "reload", "-c", "./frpc.toml")
// 确保缓存路径存在
if cachedFrpcPath == "" {
if _, err := getFrpcPath(); err != nil {
return fmt.Errorf("获取 frpc 路径失败: %w", err)
}
}
cmd := exec.Command(cachedFrpcPath, "reload", "-c", "./frpc.toml")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("reload 失败: %s, %w", string(output), err)
@@ -192,36 +209,29 @@ func ReloadFrpc() error {
// StopFrpc 停止 frpc
func StopFrpc() error {
if runtime.GOOS == "windows" {
// Windows 用 taskkill
cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe")
err := cmd.Run()
if err != nil && !strings.Contains(err.Error(), "not found") {
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") {
return fmt.Errorf("停止 frpc 失败: %w", err)
}
return nil
} else {
// Linux/Unix 用 pkill
}
cmd := exec.Command("pkill", "-f", "frpc")
err := cmd.Run()
if err != nil && !strings.Contains(err.Error(), "no process") {
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") {
return fmt.Errorf("停止 frpc 失败: %w", err)
}
return nil
}
}
// GetFrpcStatus 检查 frpc 是否在运行
func GetFrpcStatus() (bool, error) {
if runtime.GOOS == "windows" {
// Windows 用 tasklist 检查
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq frpc.exe")
output, err := cmd.CombinedOutput()
if err != nil {
return false, nil
}
return strings.Contains(string(output), "frpc.exe"), nil
} else {
// Linux/Unix 用 pgrep
}
cmd := exec.Command("pgrep", "-f", "frpc")
output, err := cmd.Output()
if err != nil {
@@ -229,4 +239,3 @@ func GetFrpcStatus() (bool, error) {
}
return len(output) > 0, nil
}
}
+15
View File
@@ -0,0 +1,15 @@
2026-07-23 19:03:20.982 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration
2026-07-23 19:03:20.991 [I] [client/service.go:308] try to connect to server...
2026-07-23 19:03:21.150 [I] [client/service.go:328] [153a390fe71d72b9] login to server success, get run id [153a390fe71d72b9]
2026-07-23 19:05:38.975 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration
2026-07-23 19:05:38.984 [I] [client/service.go:308] try to connect to server...
2026-07-23 19:05:39.171 [I] [client/service.go:328] [7e21ed8b7758eb2e] login to server success, get run id [7e21ed8b7758eb2e]
2026-07-23 19:08:07.129 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration
2026-07-23 19:08:07.142 [I] [client/service.go:308] try to connect to server...
2026-07-23 19:08:07.302 [I] [client/service.go:328] [93745a8de846e221] login to server success, get run id [93745a8de846e221]
2026-07-23 19:10:02.070 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration
2026-07-23 19:10:02.079 [I] [client/service.go:308] try to connect to server...
2026-07-23 19:10:02.297 [I] [client/service.go:328] [a6203105fbd4c067] login to server success, get run id [a6203105fbd4c067]
2026-07-23 19:19:21.669 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration
2026-07-23 19:19:21.680 [I] [client/service.go:308] try to connect to server...
2026-07-23 19:19:21.974 [I] [client/service.go:328] [5da2ae4457692b0e] login to server success, get run id [5da2ae4457692b0e]
+29
View File
@@ -0,0 +1,29 @@
serverAddr = "{{.ServerAddr}}"
serverPort = {{.ServerPort}}
[auth]
token = "{{.Token}}"
[log]
to = "./frpc.log"
level = "{{.LogLevel}}"
maxDays = {{.LogMaxDays}}
[transport]
tcpMux = {{.TcpMux}}
tcpMuxKeepaliveInterval = {{.TcpMuxKeepalive}}
heartbeatInterval = {{.HeartbeatInterval}}
heartbeatTimeout = {{.HeartbeatTimeout}}
poolCount = {{.PoolCount}}
[webServer]
addr = "127.0.0.1:7400"
{{range .Proxies}}
[[proxies]]
name = "{{.Name}}"
type = "{{.Type}}"
localIP = "{{.LocalIP}}"
localPort = {{.LocalPort}}
remotePort = {{.RemotePort}}
{{end}}
-18
View File
@@ -1,18 +0,0 @@
serverAddr = "frp.whitetop.xyz"
serverPort = 9358
[auth]
token = "Lxh10020328"
[log]
to = "./frpc.log"
level = "info"
maxDays = 3
[transport]
tcpMux = true
tcpMuxKeepaliveInterval = 30
heartbeatInterval = 15
heartbeatTimeout = 70
poolCount = 8
+25 -104
View File
@@ -1,5 +1,6 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -8,10 +9,7 @@
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Naive UI CDN -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/naive-ui@2/dist/styles.css"
/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/naive-ui@2/dist/styles.css" />
<!-- TOML 解析库(用于导入/导出) -->
<script src="https://cdn.jsdelivr.net/npm/@iarna/toml@3.0.0/dist/toml.min.js"></script>
<!-- 自定义样式 -->
@@ -35,21 +33,11 @@
<div class="login-form">
<div class="input-group">
<label>user</label>
<input
v-model="loginForm.username"
type="text"
placeholder="用户名"
@keydown.enter="doLogin"
/>
<input v-model="loginForm.username" type="text" placeholder="用户名" @keydown.enter="doLogin" />
</div>
<div class="input-group">
<label>passwd</label>
<input
v-model="loginForm.password"
type="password"
placeholder="密码"
@keydown.enter="doLogin"
/>
<input v-model="loginForm.password" type="password" placeholder="密码" @keydown.enter="doLogin" />
</div>
<button class="login-btn" @click="doLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
@@ -65,9 +53,7 @@
<div class="top-left">
<span class="logo">🚀 frpc-console</span>
<span class="status-dot" :class="{ active: frpcRunning }"></span>
<span class="status-text"
>{{ frpcRunning ? 'frpc 运行中' : 'frpc 已停止' }}</span
>
<span class="status-text">{{ frpcRunning ? 'frpc 运行中' : 'frpc 已停止' }}</span>
</div>
<div class="top-right">
<span class="user-name">{{ loginForm.username }}</span>
@@ -77,18 +63,10 @@
<!-- Tab 切换 -->
<div class="tab-bar">
<span
class="tab-item"
:class="{ active: activeTab === 'config' }"
@click="activeTab = 'config'"
>
<span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">
全局配置
</span>
<span
class="tab-item"
:class="{ active: activeTab === 'proxies' }"
@click="activeTab = 'proxies'"
>
<span class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'">
隧道列表
</span>
</div>
@@ -101,49 +79,29 @@
<div class="metric-card">
<div class="metric-label">TCP Multiplexing</div>
<div class="metric-value">
<label class="switch">
<input
type="checkbox"
v-model="globalConfig.tcpMux"
@change="saveConfig"
/>
<span class="slider"></span>
</label>
<span
class="status-text"
:class="{ active: globalConfig.tcpMux }"
>
{{ globalConfig.tcpMux ? '已启用' : '已禁用' }}
</span>
<span class="status-dot active"></span>
<span class="status-text active">已启用(固定)</span>
</div>
</div>
<div class="metric-card">
<div class="metric-label">连接池大小</div>
<div class="metric-value">
<span class="big-number"
>{{ globalConfig.poolCount || 8 }}</span
>
<span class="big-number">{{ globalConfig.poolCount || 8 }}</span>
<span class="unit"></span>
</div>
</div>
<div class="metric-card">
<div class="metric-label">心跳间隔 / 超时</div>
<div class="metric-value">
<span class="digit"
>{{ globalConfig.heartbeatInterval || 15 }}s</span
>
<span class="digit">{{ globalConfig.heartbeatInterval || 15 }}s</span>
<span class="sep">/</span>
<span class="digit"
>{{ globalConfig.heartbeatTimeout || 70 }}s</span
>
<span class="digit">{{ globalConfig.heartbeatTimeout || 70 }}s</span>
</div>
</div>
</div>
<div class="detail-collapse" @click="showDetail = !showDetail">
<span
>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span
>
<span>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span>
</div>
<div v-show="showDetail" class="detail-panel">
<div class="form-row">
@@ -178,17 +136,11 @@
</div>
<div class="form-row">
<label>Heartbeat Interval</label>
<input
type="number"
v-model="globalConfig.heartbeatInterval"
/>
<input type="number" v-model="globalConfig.heartbeatInterval" />
</div>
<div class="form-row">
<label>Heartbeat Timeout</label>
<input
type="number"
v-model="globalConfig.heartbeatTimeout"
/>
<input type="number" v-model="globalConfig.heartbeatTimeout" />
</div>
<div class="form-row">
<label>Pool Count</label>
@@ -215,31 +167,18 @@
</button>
</div>
<div class="toolbar-right">
<input
class="search-input"
placeholder="搜索隧道..."
v-model="searchKeyword"
/>
<input class="search-input" placeholder="搜索隧道..." v-model="searchKeyword" />
</div>
</div>
<!-- 隧道卡片列表 -->
<div class="proxy-list">
<div
v-for="p in filteredProxies"
:key="p.id"
class="proxy-card"
>
<div v-for="p in filteredProxies" :key="p.id" class="proxy-card">
<div class="proxy-row">
<div class="proxy-left">
<span
class="status-dot"
:class="{ active: p.enabled }"
></span>
<span class="status-dot" :class="{ active: p.enabled }"></span>
<span class="proxy-name">{{ p.name }}</span>
<span class="proxy-addr"
>{{ p.localIP }}:{{ p.localPort }}</span
>
<span class="proxy-addr">{{ p.localIP }}:{{ p.localPort }}</span>
<span class="proxy-tag">{{ p.type }}</span>
</div>
<div class="proxy-right">
@@ -247,26 +186,17 @@
<button class="icon-btn" @click="openEditDialog(p)">
</button>
<button
class="icon-btn danger"
@click="deleteProxy(p.id)"
>
<button class="icon-btn danger" @click="deleteProxy(p.id)">
</button>
</div>
</div>
<div class="proxy-footer">
<label class="switch small">
<input
type="checkbox"
v-model="p.enabled"
@change="toggleProxy(p)"
/>
<input type="checkbox" v-model="p.enabled" @change="toggleProxy(p)" />
<span class="slider"></span>
</label>
<span class="status-label"
>{{ p.enabled ? '已启用' : '已禁用' }}</span
>
<span class="status-label">{{ p.enabled ? '已启用' : '已禁用' }}</span>
</div>
</div>
<div v-if="filteredProxies.length === 0" class="empty-state">
@@ -279,11 +209,7 @@
</div>
<!-- ====== 新增/编辑弹窗 ====== -->
<div
v-if="dialogVisible"
class="dialog-overlay"
@click.self="dialogVisible = false"
>
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
<div class="dialog-card">
<h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3>
<div class="dialog-form">
@@ -323,16 +249,11 @@
</div>
<!-- ====== 隐藏的文件输入(导入 TOML ====== -->
<input
type="file"
id="tomlFileInput"
accept=".toml"
style="display: none"
@change="handleImport"
/>
<input type="file" id="tomlFileInput" accept=".toml" style="display: none" @change="handleImport" />
</div>
<!-- 应用逻辑 -->
<script src="/static/app.js"></script>
</body>
</html>
View File