加入首次使用的引导页、写入readme文件

This commit is contained in:
2026-07-23 22:17:48 +08:00
parent 892fc98be2
commit 55fb5d49f7
11 changed files with 766 additions and 1828 deletions
+134 -31
View File
@@ -11,59 +11,126 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// SetupRouter 配置所有路由
func SetupRouter() *gin.Engine { func SetupRouter() *gin.Engine {
r := gin.Default() r := gin.Default()
// 静态文件(前端)
r.Static("/static", "./static") r.Static("/static", "./static")
// 健康检查
r.GET("/ping", func(c *gin.Context) { r.GET("/ping", func(c *gin.Context) {
c.String(200, "frpc-console 后端已启动 🎉") c.String(200, "frpc-console 后端已启动 🎉")
}) })
// 前端入口
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
c.File("./static/index.html") c.File("./static/index.html")
}) })
// API 路由组
api := r.Group("/api") api := r.Group("/api")
{ {
// 登录(无需鉴权) // 公开接口
api.GET("/check/users", checkUsersHandler)
api.POST("/register", registerHandler)
api.POST("/login", loginHandler) api.POST("/login", loginHandler)
// 需鉴权的路由 // 需鉴权
auth := api.Group("/") auth := api.Group("/")
auth.Use(AuthMiddleware()) auth.Use(AuthMiddleware())
{ {
// 全局配置
auth.GET("/config", getConfigHandler) auth.GET("/config", getConfigHandler)
auth.PUT("/config", updateConfigHandler) auth.PUT("/config", updateConfigHandler)
// 隧道管理
auth.GET("/proxies", getProxiesHandler) auth.GET("/proxies", getProxiesHandler)
auth.GET("/proxy/:id", getProxyHandler) auth.GET("/proxy/:id", getProxyHandler)
auth.POST("/proxy", createProxyHandler) auth.POST("/proxy", createProxyHandler)
auth.PUT("/proxy/:id", updateProxyHandler) auth.PUT("/proxy/:id", updateProxyHandler)
auth.DELETE("/proxy/:id", deleteProxyHandler) auth.DELETE("/proxy/:id", deleteProxyHandler)
// frpc 控制
auth.POST("/frpc/reload", reloadFrpcHandler) auth.POST("/frpc/reload", reloadFrpcHandler)
auth.POST("/frpc/start", startFrpcHandler) auth.POST("/frpc/start", startFrpcHandler)
auth.POST("/frpc/stop", stopFrpcHandler) auth.POST("/frpc/stop", stopFrpcHandler)
auth.GET("/frpc/status", getFrpcStatusHandler) auth.GET("/frpc/status", getFrpcStatusHandler)
// 🟢 导入 TOML(新增)
auth.POST("/import/toml", importTomlHandler) auth.POST("/import/toml", importTomlHandler)
auth.GET("/export/toml", ExportTomlHandler) auth.GET("/export/toml", ExportTomlHandler)
auth.PUT("/user/password", changePasswordHandler)
} }
} }
return r return r
} }
// ========== 检查是否有用户 ==========
func checkUsersHandler(c *gin.Context) {
count, err := CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{
"hasUsers": count > 0,
"count": count,
},
})
}
// ========== 注册(仅首次启动可用) ==========
func registerHandler(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
count, err := CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"})
return
}
if count > 0 {
c.JSON(http.StatusForbidden, gin.H{"code": 1, "msg": "已存在管理员账户,请登录"})
return
}
if len(req.Username) < 5 {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "用户名至少 5 位"})
return
}
if !ValidatePassword(req.Password) {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符",
})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"})
return
}
if err := CreateUser(req.Username, string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建用户失败: " + err.Error()})
return
}
token, err := GenerateJWT(req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "注册成功",
"data": gin.H{"token": token},
})
}
// ========== 登录 ========== // ========== 登录 ==========
func loginHandler(c *gin.Context) { func loginHandler(c *gin.Context) {
var req struct { var req struct {
@@ -99,6 +166,56 @@ func loginHandler(c *gin.Context) {
}) })
} }
// ========== 修改密码 ==========
func changePasswordHandler(c *gin.Context) {
var req struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未登录"})
return
}
user, err := GetUserByUsername(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "用户不存在"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "当前密码错误"})
return
}
if !ValidatePassword(req.NewPassword) {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符",
})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"})
return
}
if err := UpdatePassword(username.(string), string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"})
}
// ========== 全局配置 ========== // ========== 全局配置 ==========
func getConfigHandler(c *gin.Context) { func getConfigHandler(c *gin.Context) {
cfg, err := GetGlobalConfig() cfg, err := GetGlobalConfig()
@@ -115,8 +232,6 @@ func updateConfigHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return return
} }
// 强制 tcpMux 为 true
cfg.TcpMux = true cfg.TcpMux = true
if err := UpdateGlobalConfig(&cfg); err != nil { if err := UpdateGlobalConfig(&cfg); err != nil {
@@ -188,7 +303,6 @@ func updateProxyHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的ID"}) c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的ID"})
return return
} }
var p Proxy var p Proxy
if err := c.ShouldBindJSON(&p); err != nil { if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
@@ -267,7 +381,7 @@ func getFrpcStatusHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}}) c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}})
} }
// ========== 🟢 导入 TOML(新增) ========== // ========== 导入 TOML ==========
func importTomlHandler(c *gin.Context) { func importTomlHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
@@ -294,20 +408,13 @@ func importTomlHandler(c *gin.Context) {
return return
} }
// 更新全局配置(保持当前 tcpMux 值)
cfg := parsed.ToGlobalConfig() cfg := parsed.ToGlobalConfig()
currentCfg, _ := GetGlobalConfig() cfg.TcpMux = true
if currentCfg != nil {
cfg.TcpMux = currentCfg.TcpMux // 保持当前值不变
}
cfg.TcpMux = true // 强制开启
if err := UpdateGlobalConfig(cfg); err != nil { if err := UpdateGlobalConfig(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
return return
} }
// 清空并导入隧道
if _, err := DB.Exec("DELETE FROM proxies"); err != nil { if _, err := DB.Exec("DELETE FROM proxies"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "清空隧道失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "清空隧道失败"})
return return
@@ -321,13 +428,11 @@ func importTomlHandler(c *gin.Context) {
} }
} }
// 生成配置并热加载
if err := GenerateFrpcConfig(); err != nil { if err := GenerateFrpcConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
return return
} }
if err := ReloadFrpc(); err != nil { if err := ReloadFrpc(); err != nil {
// 导入成功但热加载失败,不影响整体结果
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"msg": fmt.Sprintf("导入成功!共 %d 条隧道,但热加载失败: %s", len(proxies), err.Error()), "msg": fmt.Sprintf("导入成功!共 %d 条隧道,但热加载失败: %s", len(proxies), err.Error()),
@@ -341,7 +446,7 @@ func importTomlHandler(c *gin.Context) {
}) })
} }
// ExportTomlHandler 导出 TOML 配置文件 // ========== 导出 TOML ==========
func ExportTomlHandler(c *gin.Context) { func ExportTomlHandler(c *gin.Context) {
cfg, err := GetGlobalConfig() cfg, err := GetGlobalConfig()
if err != nil { if err != nil {
@@ -370,7 +475,6 @@ func ExportTomlHandler(c *gin.Context) {
Proxies: activeProxies, Proxies: activeProxies,
} }
// 解析模板(直接使用 frp.go 中的 frpcTemplateContent
tmpl, err := template.New("frpc").Parse(FrpcTemplateContent) tmpl, err := template.New("frpc").Parse(FrpcTemplateContent)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()})
@@ -383,13 +487,12 @@ func ExportTomlHandler(c *gin.Context) {
return return
} }
// 返回文件下载
c.Header("Content-Type", "text/plain; charset=utf-8") c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename=frpc.toml") c.Header("Content-Disposition", "attachment; filename=frpc.toml")
c.String(http.StatusOK, buf.String()) c.String(http.StatusOK, buf.String())
} }
// ========== 辅助函数 ========== // ========== 辅助 ==========
func generateAndReload() error { func generateAndReload() error {
if err := GenerateFrpcConfig(); err != nil { if err := GenerateFrpcConfig(); err != nil {
return err return err
+19 -21
View File
@@ -13,12 +13,18 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var jwtSecretCache []byte
func getJwtSecret() []byte { func getJwtSecret() []byte {
if len(jwtSecretCache) > 0 {
return jwtSecretCache
}
secret, err := GetJwtSecret() secret, err := GetJwtSecret()
if err != nil { if err != nil {
log.Fatal("❌ 获取 JWT 密钥失败:", err) log.Fatal("❌ 获取 JWT 密钥失败:", err)
} }
return []byte(secret) jwtSecretCache = []byte(secret)
return jwtSecretCache
} }
type Claims struct { type Claims struct {
@@ -51,49 +57,45 @@ func ParseJWT(tokenString string) (*Claims, error) {
return nil, errors.New("invalid token") return nil, errors.New("invalid token")
} }
// 密码复杂度验证:至少8位密码,包含大写字母、数字和特殊字符
func ValidatePassword(pwd string) bool { func ValidatePassword(pwd string) bool {
if len(pwd) < 8 { if len(pwd) < 8 {
return false return false
} }
var hasLetter, hasDigit bool var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, ch := range pwd { for _, ch := range pwd {
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') { if ch >= 'A' && ch <= 'Z' {
hasLetter = true hasUpper = true
} } else if ch >= 'a' && ch <= 'z' {
if ch >= '0' && ch <= '9' { hasLower = true
} else if ch >= '0' && ch <= '9' {
hasDigit = true hasDigit = true
} else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") {
hasSpecial = true
} }
} }
return hasLetter && hasDigit return hasUpper && hasLower && hasDigit && hasSpecial
} }
// 首次启动强制设置管理员账户
func InitAdminUser() { func InitAdminUser() {
count, err := CountUsers() count, err := CountUsers()
if err != nil { if err != nil {
log.Fatal("❌ 检查用户表失败:", err) log.Fatal("❌ 检查用户表失败:", err)
} }
if count > 0 { if count > 0 {
log.Println("✅ 已存在管理员账户,跳过初始化") log.Println("✅ 已存在管理员账户,跳过初始化")
return return
} }
log.Println("⚠️ 首次启动,请设置管理员账户") log.Println("⚠️ 首次启动,请设置管理员账户")
log.Print("用户名: ") log.Print("用户名: ")
var username string var username string
fmt.Scanln(&username) fmt.Scanln(&username)
if username == "" { if username == "" {
username = "admin" username = "admin"
} }
for { for {
log.Print("密码 (至少8位密码,包含大写字母、数字特殊字符): ") log.Print("密码 (至少8位,含大小写、数字特殊字符): ")
var password string var password string
fmt.Scanln(&password) fmt.Scanln(&password)
if ValidatePassword(password) { if ValidatePassword(password) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -109,12 +111,11 @@ func InitAdminUser() {
break break
} else { } else {
log.Println("❌ 密码不符合复杂度要求,请重新输入") log.Println("❌ 密码不符合复杂度要求,请重新输入")
log.Println(" 要求: 至少8位密码,包含大写字母、数字和特殊字符") log.Println(" 要求: 至少8位,包含大写字母、数字和特殊字符")
} }
} }
} }
// JWT 认证中间件
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
@@ -123,21 +124,18 @@ func AuthMiddleware() gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
// 格式 "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" { if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"}) c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"})
c.Abort() c.Abort()
return return
} }
tokenString := parts[1] claims, err := ParseJWT(parts[1])
claims, err := ParseJWT(tokenString)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"}) c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"})
c.Abort() c.Abort()
return return
} }
// 将用户名存入上下文,方便后续使用
c.Set("username", claims.Username) c.Set("username", claims.Username)
c.Next() c.Next()
} }
+11 -18
View File
@@ -5,13 +5,11 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"log" "log"
"sync"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
var DB *sql.DB var DB *sql.DB
var dbInitOnce sync.Once
// ===== 全局配置表(A表)===== // ===== 全局配置表(A表)=====
type GlobalConfig struct { type GlobalConfig struct {
@@ -90,7 +88,6 @@ func InitDB() error {
return err return err
} }
// 确保全局配置有一行数据
var count int var count int
DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count)
if count == 0 { if count == 0 {
@@ -124,7 +121,7 @@ func InitDB() error {
return err return err
} }
// 🟢 新增:应用配置表(用于存储 JWT 密钥等全局配置 // 应用配置表(JWT密钥等)
_, err = DB.Exec(` _, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS app_config ( CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -136,7 +133,6 @@ func InitDB() error {
return err return err
} }
// 🟢 确保 JWT 密钥存在
if err := ensureJwtSecret(); err != nil { if err := ensureJwtSecret(); err != nil {
return err return err
} }
@@ -145,25 +141,20 @@ func InitDB() error {
return nil return nil
} }
// ===== 🟢 新增:JWT 密钥管理 ===== // ===== JWT 密钥管理 =====
// ensureJwtSecret 检查 app_config 表里是否有 jwt_secret,没有则生成一个
func ensureJwtSecret() error { func ensureJwtSecret() error {
var value string var value string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value) err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value)
if err == nil && value != "" { if err == nil && value != "" {
// 密钥已存在
return nil return nil
} }
// 生成 32 字节随机密钥
bytes := make([]byte, 32) bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return err return err
} }
secret := hex.EncodeToString(bytes) secret := hex.EncodeToString(bytes)
// 存入数据库
_, err = DB.Exec(` _, err = DB.Exec(`
INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?) INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?)
`, secret) `, secret)
@@ -174,7 +165,6 @@ func ensureJwtSecret() error {
return nil return nil
} }
// GetJwtSecret 从数据库读取 JWT 密钥
func GetJwtSecret() (string, error) { func GetJwtSecret() (string, error) {
var secret string var secret string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret) err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret)
@@ -199,17 +189,15 @@ func GetGlobalConfig() (*GlobalConfig, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 🟢 强制开启 tcpMux,忽略数据库中存储的值
cfg.TcpMux = true cfg.TcpMux = true
return &cfg, nil return &cfg, nil
} }
func UpdateGlobalConfig(cfg *GlobalConfig) error { func UpdateGlobalConfig(cfg *GlobalConfig) error {
// 忽略前端传过来的 TcpMux,强制保持为 true
_, err := DB.Exec(` _, err := DB.Exec(`
UPDATE global_config SET UPDATE global_config SET
server_addr = ?, server_port = ?, token = ?, log_level = ?, log_max_days = ?, server_addr = ?, server_port = ?, token = ?, log_level = ?, log_max_days = ?,
tcp_mux = 1, -- 🟢 强制写死为 1 tcp_mux = 1,
tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?, tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = 1 WHERE id = 1
@@ -238,12 +226,9 @@ func GetProxies() ([]Proxy, error) {
} }
proxies = append(proxies, p) proxies = append(proxies, p)
} }
// 🟢 关键:检查遍历过程中是否有错误
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return nil, err return nil, err
} }
return proxies, nil return proxies, nil
} }
@@ -307,6 +292,14 @@ func CreateUser(username, passwordHash string) error {
return err return err
} }
func UpdatePassword(username, passwordHash string) error {
_, err := DB.Exec(`
UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, passwordHash, username)
return err
}
func CountUsers() (int, error) { func CountUsers() (int, error) {
var count int var count int
err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
BIN
View File
Binary file not shown.
+3 -1476
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -19,80 +19,3 @@ poolCount = 8
[webServer] [webServer]
addr = "127.0.0.1:7400" addr = "127.0.0.1:7400"
[[proxies]]
name = "sun-panel"
type = "tcp"
localIP = "192.168.3.16"
localPort = 3002
remotePort = 30005
[[proxies]]
name = "1panel-1"
type = "tcp"
localIP = "192.168.3.16"
localPort = 10086
remotePort = 20003
[[proxies]]
name = "navidrome"
type = "tcp"
localIP = "192.168.3.16"
localPort = 4533
remotePort = 20022
[[proxies]]
name = "openlist"
type = "tcp"
localIP = "192.168.3.16"
localPort = 5244
remotePort = 20005
[[proxies]]
name = "SSH"
type = "tcp"
localIP = "192.168.3.16"
localPort = 22
remotePort = 20001
[[proxies]]
name = "SMB"
type = "tcp"
localIP = "192.168.3.16"
localPort = 445
remotePort = 20008
[[proxies]]
name = "napcat"
type = "tcp"
localIP = "192.168.3.16"
localPort = 6099
remotePort = 20007
[[proxies]]
name = "Gitea"
type = "tcp"
localIP = "192.168.3.16"
localPort = 3800
remotePort = 20017
[[proxies]]
name = "immich-APP"
type = "tcp"
localIP = "192.168.3.16"
localPort = 2283
remotePort = 20020
[[proxies]]
name = "immich"
type = "tcp"
localIP = "192.168.3.16"
localPort = 2283
remotePort = 20015
[[proxies]]
name = "napcat-2"
type = "tcp"
localIP = "192.168.3.16"
localPort = 6100
remotePort = 20012
+189
View File
@@ -0,0 +1,189 @@
# 🚀 frpc-console
> 轻量级 frpc 管理面板 —— 为你的内网穿透插上翅膀
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com)
---
## 📖 简介
**frpc-console** 是一个专为 [frp](https://github.com/fatedier/frp) 设计的轻量级管理工具。它解决了 frpc 配置文件手动维护繁琐、缺少可视化界面的痛点,让你在浏览器中轻松管理所有隧道。
### ✨ 核心特性
- 🔐 **首次启动引导** —— Web 端完成管理员注册,无需 CLI 交互
- 📋 **隧道全生命周期管理** —— 增删改查 + 一键启用/禁用
- 📦 **导入/导出 TOML** —— 无缝迁移现有 frpc 配置
- 🔄 **配置热加载** —— 修改即生效,无需重启 frpc
- 🖥️ **多平台支持** —— Windows / Linux / ARM 全平台兼容
- 🐳 **容器化就绪** —— 提供 Docker 镜像,开箱即用
- 🎨 **深色磨砂玻璃 UI** —— 现代化视觉体验,日夜皆宜
### 🎯 与 Podux 的对比
| 特性 | frpc-console | Podux |
|---|---|---|
| 数据库 | SQLite (几 MB) | PocketBase (数百 MB) |
| 前端 | Vue CDN (无需构建) | React + Webpack |
| 内存占用 | ~50 MB | 1GB+ (实测 OOM) |
| 部署方式 | 单二进制 / Docker | Docker 专用 |
| 多平台支持 | ✅ 原生交叉编译 | ❌ 需自行构建 |
---
## 🚀 快速开始
### 方式一:直接运行二进制
从 [Releases](https://github.com/lxh2875931338/frpc-console/releases) 下载对应平台版本:
```bash
# Linux / macOS
chmod +x frpc-console
./frpc-console
# Windows
frpc-console.exe
```
首次启动会在终端提示设置管理员账户,之后访问 http://localhost:8080 即可。
### 方式二:Docker 运行
```bash
docker run -d \
--name frpc-console \
-p 8080:8080 \
-v ./data:/app/data \
lxh2875931338/frpc-console:latest
```
### 方式三:源码编译
```bash
git clone https://github.com/lxh2875931338/frpc-console.git
cd frpc-console
go mod tidy
go build -o frpc-console .
./frpc-console
```
## 🗂️ 项目结构
```txt
frpc-console/
├── main.go # 入口
├── api.go # HTTP 路由 & Handler
├── db.go # SQLite 数据库操作
├── auth.go # JWT 认证 & 密码管理
├── frp.go # frpc 管理核心逻辑
├── toml_parser.go # TOML 解析器
├── static/ # 前端静态资源
│ ├── index.html
│ ├── app.js
│ ├── style-1.css # 全局基础样式
│ ├── style-2.css # 登录页样式
│ └── style-3.css # 主界面样式
├── bin/ # 内嵌 frpc 二进制 (多平台)
│ ├── frpc_windows_amd64.exe
│ ├── frpc_linux_amd64
│ └── frpc_linux_arm64
├── frpc.tmpl # frpc 配置模板
├── Dockerfile
└── go.mod
```
## ⚙️ 配置说明
### 环境变量
| 变量 | 说明 | 默认值 |
|---|---|---|
| PORT | 监听端口 | 8080 |
### 数据存储
· 数据库文件:./frpc-console.db
· frpc 配置文件:./frpc.toml
· frpc 日志文件:./frpc.log
---
## 🛠️ 开发指南
```bash
# 克隆项目
git clone https://github.com/lxh2875931338/frpc-console.git
cd frpc-console
# 安装依赖
go mod tidy
# 开发模式运行
go run .
# 编译生产版本
go build -ldflags="-s -w" -o frpc-console .
```
## 前端开发
前端使用 Vue 3 CDN + Naive UI,无需额外构建工具。修改 static/ 目录下的文件后,刷新浏览器即可预览效果。
---
### 📦 构建 Docker 镜像
```bash
# 构建 amd64 镜像
docker build -t frpc-console:amd64 .
# 多架构构建 (需要 buildx)
docker buildx build --platform linux/amd64,linux/arm64 -t frpc-console:latest --push .
```
---
## 🔧 常见问题
#### Q: 如何修改管理员密码?
登录后,在「全局配置」页面顶部找到「账户管理」区域,输入当前密码和新密码即可。
#### Q: 如何导入现有的 frpc.toml
在「隧道列表」页面点击「导入 TOML」,选择你的 frpc.toml 文件即可。
#### Q: frpc 启动失败怎么办?
检查 frpc.toml 配置是否正确,或查看 ./frpc.log 日志文件。
#### Q: 支持哪些 frp 版本?
目前支持情况如下:
| 系统 | 架构版本 | 版本号 |
|---|---|---|
| Linux | AMD64/x86-64 | 0.70.0 |
| Linux | ARM64/Aarch64 | 0.70.0 |
| Windows | AMD64/x86-64 | 0.69.0 |
---
## 📝 更新日志
### v0.1.0 (2026-07-23)
· 🎉 首次发布
· ✨ 支持隧道增删改查
· ✨ 支持 TOML 导入/导出
· ✨ 首次启动 Web 注册
· ✨ 深色磨砂玻璃 UI
· ✨ 多平台 frpc 自动适配
· 🐳 Docker 镜像支持
---
+188 -83
View File
@@ -1,12 +1,8 @@
// app.js - 普通脚本(非模块)
const { createApp, ref, reactive, computed, onMounted } = Vue; const { createApp, ref, reactive, computed, onMounted } = Vue;
// ============================================================ // ============================================================
// 1. 把 modules/*.js 的内容直接合并进来(因为是普通脚本,不能 import) // API
// ============================================================ // ============================================================
// ---------- api ----------
const API_BASE = "/api"; const API_BASE = "/api";
function getToken() { function getToken() {
return localStorage.getItem("frpc_token") || ""; return localStorage.getItem("frpc_token") || "";
@@ -19,10 +15,9 @@ function getHeaders() {
} }
async function apiFetch(endpoint, options = {}) { async function apiFetch(endpoint, options = {}) {
const url = API_BASE + endpoint; const url = API_BASE + endpoint;
const headers = const headers = options.body instanceof FormData
options.body instanceof FormData ? { Authorization: "Bearer " + getToken() }
? { Authorization: "Bearer " + getToken() } : getHeaders();
: getHeaders();
const res = await fetch(url, { const res = await fetch(url, {
...options, ...options,
headers: { ...headers, ...(options.headers || {}) }, headers: { ...headers, ...(options.headers || {}) },
@@ -30,7 +25,9 @@ async function apiFetch(endpoint, options = {}) {
return res.json(); return res.json();
} }
// ---------- auth ---------- // ============================================================
// Auth
// ============================================================
function parseJwtExpire(token) { function parseJwtExpire(token) {
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
@@ -74,7 +71,9 @@ async function login(username, password) {
return { success: false, error: data.msg || "登录失败" }; return { success: false, error: data.msg || "登录失败" };
} }
// ---------- config ---------- // ============================================================
// Config
// ============================================================
const defaultConfig = { const defaultConfig = {
serverAddr: "frp.whitetop.xyz", serverAddr: "frp.whitetop.xyz",
serverPort: 9358, serverPort: 9358,
@@ -106,7 +105,9 @@ async function saveConfigApi(config) {
return data; return data;
} }
// ---------- proxies ---------- // ============================================================
// Proxies
// ============================================================
async function loadProxies() { async function loadProxies() {
try { try {
const data = await apiFetch("/proxies"); const data = await apiFetch("/proxies");
@@ -141,15 +142,16 @@ async function deleteProxy(id) {
function filterProxies(list, keyword) { function filterProxies(list, keyword) {
if (!keyword) return list; if (!keyword) return list;
const kw = keyword.toLowerCase(); const kw = keyword.toLowerCase();
return list.filter( return list.filter(p =>
(p) => p.name.toLowerCase().includes(kw) ||
p.name.toLowerCase().includes(kw) || p.localIP.includes(kw) ||
p.localIP.includes(kw) || String(p.remotePort).includes(kw)
String(p.remotePort).includes(kw),
); );
} }
// ---------- frpc ---------- // ============================================================
// Frpc
// ============================================================
async function getFrpcStatus() { async function getFrpcStatus() {
try { try {
const data = await apiFetch("/frpc/status"); const data = await apiFetch("/frpc/status");
@@ -161,27 +163,11 @@ async function getFrpcStatus() {
} }
return false; return false;
} }
async function reloadFrpc() {
const data = await apiFetch("/frpc/reload", { method: "POST" });
return data;
}
async function startFrpc() {
const data = await apiFetch("/frpc/start", { method: "POST" });
return data;
}
async function stopFrpc() {
const data = await apiFetch("/frpc/stop", { method: "POST" });
return data;
}
// ---------- ui ---------- // ============================================================
// Tab 切换 // UI State
// ============================================================
const activeTab = ref("proxies"); const activeTab = ref("proxies");
function switchTab(tab) {
activeTab.value = tab;
}
// 弹窗控制
const dialogVisible = ref(false); const dialogVisible = ref(false);
const dialogMode = ref("add"); const dialogMode = ref("add");
const dialogForm = ref({ const dialogForm = ref({
@@ -192,17 +178,11 @@ const dialogForm = ref({
localPort: 0, localPort: 0,
remotePort: 0, remotePort: 0,
}); });
const searchKeyword = ref("");
function openAddDialog() { function openAddDialog() {
dialogMode.value = "add"; dialogMode.value = "add";
dialogForm.value = { dialogForm.value = { id: null, name: "", type: "tcp", localIP: "", localPort: 0, remotePort: 0 };
id: null,
name: "",
type: "tcp",
localIP: "",
localPort: 0,
remotePort: 0,
};
dialogVisible.value = true; dialogVisible.value = true;
} }
function openEditDialog(proxy) { function openEditDialog(proxy) {
@@ -214,9 +194,9 @@ function closeDialog() {
dialogVisible.value = false; dialogVisible.value = false;
} }
const searchKeyword = ref(""); // ============================================================
// Import / Export
// ---------- import/export 功能 ---------- // ============================================================
async function triggerImport() { async function triggerImport() {
document.getElementById("tomlFileInput").click(); document.getElementById("tomlFileInput").click();
} }
@@ -244,42 +224,35 @@ async function handleImport(e) {
e.target.value = ""; e.target.value = "";
} }
async function exportToml() { async function exportToml() {
console.log('🔍 exportToml 被调用了');
try { try {
const res = await fetch('/api/export/toml', { const res = await fetch("/api/export/toml", {
method: 'GET', method: "GET",
headers: { 'Authorization': 'Bearer ' + getToken() }, headers: { Authorization: "Bearer " + getToken() },
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
alert('导出失败: ' + (data.msg || '未知错误')); alert("导出失败: " + (data.msg || "未知错误"));
return; return;
} }
const blob = await res.blob(); const blob = await res.blob();
const a = document.createElement('a'); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = 'frpc.toml'; a.download = "frpc.toml";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
console.log('✅ 导出成功');
} catch (e) { } catch (e) {
console.error('导出失败:', e); alert("导出失败: " + e.message);
alert('导出失败: ' + e.message);
} }
} }
// ============================================================ // ============================================================
// 2. Vue 应用 // Vue App
// ============================================================ // ============================================================
const app = createApp({ const app = createApp({
setup() { setup() {
// ===== 登录态 ===== // ---- 登录态 ----
const loggedIn = ref(false); const loggedIn = ref(false);
const loading = ref(false); const loading = ref(false);
const loginError = ref(""); const loginError = ref("");
@@ -290,13 +263,32 @@ const app = createApp({
const contentVisible = ref(false); const contentVisible = ref(false);
let expireTimer = null; let expireTimer = null;
// ===== 数据 ===== // ---- 注册 ----
const isFirstRun = ref(false);
const registerForm = reactive({
username: "",
password: "",
confirmPassword: "",
});
const registerError = ref("");
const registering = ref(false);
// ---- 数据 ----
const globalConfig = reactive({}); const globalConfig = reactive({});
const proxies = ref([]); const proxies = ref([]);
const frpcRunning = ref(false); const frpcRunning = ref(false);
const showDetail = ref(false); const showDetail = ref(false);
// ===== 初始化 ===== // ---- 修改密码 ----
const passwordChange = reactive({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
const passwordChangeError = ref("");
const passwordChangeSuccess = ref("");
// ---- 初始化 ----
onMounted(() => { onMounted(() => {
const saved = loadAuthState(); const saved = loadAuthState();
if (saved.valid) { if (saved.valid) {
@@ -308,10 +300,24 @@ const app = createApp({
contentVisible.value = true; contentVisible.value = true;
startExpireTimer(); startExpireTimer();
}); });
} else {
checkUsers();
} }
}); });
const startExpireTimer = () => { async function checkUsers() {
try {
const res = await fetch("/api/check/users");
const data = await res.json();
if (data.code === 0) {
isFirstRun.value = !data.data.hasUsers;
}
} catch (e) {
console.warn("检查用户状态失败", e);
}
}
function startExpireTimer() {
if (expireTimer) clearInterval(expireTimer); if (expireTimer) clearInterval(expireTimer);
expireTimer = setInterval(() => { expireTimer = setInterval(() => {
if (Date.now() >= tokenExpireTime.value) { if (Date.now() >= tokenExpireTime.value) {
@@ -319,9 +325,9 @@ const app = createApp({
alert("登录已超时,请重新登录"); alert("登录已超时,请重新登录");
} }
}, 5000); }, 5000);
}; }
const loadAllData = async () => { async function loadAllData() {
const [cfg, list, running] = await Promise.all([ const [cfg, list, running] = await Promise.all([
loadConfig(), loadConfig(),
loadProxies(), loadProxies(),
@@ -330,9 +336,9 @@ const app = createApp({
Object.assign(globalConfig, cfg); Object.assign(globalConfig, cfg);
proxies.value = list; proxies.value = list;
frpcRunning.value = running; frpcRunning.value = running;
}; }
// ===== 登录/退出 ===== // ---- 登录 ----
const doLogin = async () => { const doLogin = async () => {
if (!loginForm.username || !loginForm.password) { if (!loginForm.username || !loginForm.password) {
loginError.value = "请输入用户名和密码"; loginError.value = "请输入用户名和密码";
@@ -361,6 +367,58 @@ const app = createApp({
} }
}; };
// ---- 注册 ----
const doRegister = async () => {
if (registerForm.username.length < 5) {
registerError.value = "用户名至少 5 位";
return;
}
if (registerForm.password !== registerForm.confirmPassword) {
registerError.value = "两次输入的密码不一致";
return;
}
if (registerForm.password.length < 8) {
registerError.value = "密码至少 8 位";
return;
}
registering.value = true;
registerError.value = "";
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: registerForm.username,
password: registerForm.password,
}),
});
const data = await res.json();
if (data.code === 0) {
const token = data.data.token;
const expire = saveAuthState(token, registerForm.username);
token.value = token;
tokenExpireTime.value = expire;
loginForm.username = registerForm.username;
loggedIn.value = true;
transitioning.value = true;
setTimeout(async () => {
transitioning.value = false;
await loadAllData();
setTimeout(() => {
contentVisible.value = true;
startExpireTimer();
}, 300);
}, 500);
} else {
registerError.value = data.msg || "注册失败";
}
} catch (e) {
registerError.value = "网络错误,请重试";
}
registering.value = false;
};
// ---- 退出 ----
const doLogout = () => { const doLogout = () => {
if (expireTimer) clearInterval(expireTimer); if (expireTimer) clearInterval(expireTimer);
contentVisible.value = false; contentVisible.value = false;
@@ -374,7 +432,42 @@ const app = createApp({
clearAuthState(); clearAuthState();
}; };
// ===== 保存配置 ===== // ---- 修改密码 ----
const changePassword = async () => {
passwordChangeError.value = "";
passwordChangeSuccess.value = "";
if (passwordChange.newPassword !== passwordChange.confirmPassword) {
passwordChangeError.value = "两次输入的密码不一致";
return;
}
if (passwordChange.newPassword.length < 8) {
passwordChangeError.value = "密码至少 8 位";
return;
}
try {
const res = await fetch("/api/user/password", {
method: "PUT",
headers: getHeaders(),
body: JSON.stringify({
oldPassword: passwordChange.oldPassword,
newPassword: passwordChange.newPassword,
}),
});
const data = await res.json();
if (data.code === 0) {
passwordChangeSuccess.value = "密码修改成功!";
passwordChange.oldPassword = "";
passwordChange.newPassword = "";
passwordChange.confirmPassword = "";
} else {
passwordChangeError.value = data.msg || "修改失败";
}
} catch (e) {
passwordChangeError.value = "网络错误,请重试";
}
};
// ---- 保存配置 ----
const saveConfig = async () => { const saveConfig = async () => {
const result = await saveConfigApi(globalConfig); const result = await saveConfigApi(globalConfig);
if (result.code === 0) { if (result.code === 0) {
@@ -385,7 +478,7 @@ const app = createApp({
} }
}; };
// ===== 隧道操作 ===== // ---- 隧道操作 ----
const toggleProxy = async (p) => { const toggleProxy = async (p) => {
const result = await updateProxy(p); const result = await updateProxy(p);
if (result.code !== 0) { if (result.code !== 0) {
@@ -395,9 +488,7 @@ const app = createApp({
frpcRunning.value = await getFrpcStatus(); frpcRunning.value = await getFrpcStatus();
} }
}; };
const removeProxy = async (id) => { const removeProxy = async (id) => {
console.log("🔍 removeProxy 被调用, id:", id); // ← 加这行
if (!confirm("确定要删除这条隧道吗?")) return; if (!confirm("确定要删除这条隧道吗?")) return;
const result = await deleteProxy(id); const result = await deleteProxy(id);
if (result.code === 0) { if (result.code === 0) {
@@ -407,17 +498,15 @@ const app = createApp({
alert("删除失败: " + result.msg); alert("删除失败: " + result.msg);
} }
}; };
const confirmDialog = async () => { const confirmDialog = async () => {
const form = dialogForm.value; const form = dialogForm.value;
if (!form.name || !form.localIP || !form.localPort || !form.remotePort) { if (!form.name || !form.localIP || !form.localPort || !form.remotePort) {
alert("请填写完整信息"); alert("请填写完整信息");
return; return;
} }
const result = const result = dialogMode.value === "add"
dialogMode.value === "add" ? await createProxy(form)
? await createProxy(form) : await updateProxy(form);
: await updateProxy(form);
if (result.code === 0) { if (result.code === 0) {
closeDialog(); closeDialog();
proxies.value = await loadProxies(); proxies.value = await loadProxies();
@@ -427,11 +516,12 @@ const app = createApp({
} }
}; };
// ===== 计算属性 ===== // ---- 计算属性 ----
const filteredProxies = computed(() => const filteredProxies = computed(() =>
filterProxies(proxies.value, searchKeyword.value), filterProxies(proxies.value, searchKeyword.value)
); );
// ---- 返回 ----
return { return {
loggedIn, loggedIn,
loading, loading,
@@ -439,16 +529,24 @@ const app = createApp({
loginForm, loginForm,
doLogin, doLogin,
doLogout, doLogout,
isFirstRun,
registerForm,
registerError,
registering,
doRegister,
activeTab, activeTab,
switchTab,
globalConfig, globalConfig,
showDetail, showDetail,
saveConfig, saveConfig,
proxies, proxies,
filteredProxies, filteredProxies,
toggleProxy, toggleProxy,
removeProxy, removeProxy,
frpcRunning, frpcRunning,
dialogVisible, dialogVisible,
dialogMode, dialogMode,
dialogForm, dialogForm,
@@ -456,12 +554,19 @@ const app = createApp({
openEditDialog, openEditDialog,
closeDialog, closeDialog,
confirmDialog, confirmDialog,
searchKeyword, searchKeyword,
transitioning, transitioning,
contentVisible, contentVisible,
triggerImport, triggerImport,
handleImport, handleImport,
exportToml, exportToml,
passwordChange,
passwordChangeError,
passwordChangeSuccess,
changePassword,
}; };
}, },
}); });
+129 -121
View File
@@ -8,11 +8,7 @@
<!-- Vue 3 CDN --> <!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <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" />
<!-- 不再需要前端 TOML 库,已改用后端导出 -->
<!--<script src="https://cdn.jsdelivr.net/npm/@iarna/toml@3.0.0/dist/toml.min.js"></script>-->
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/style-1.css" /> <link rel="stylesheet" href="/static/style-1.css" />
<link rel="stylesheet" href="/static/style-2.css" /> <link rel="stylesheet" href="/static/style-2.css" />
<link rel="stylesheet" href="/static/style-3.css" /> <link rel="stylesheet" href="/static/style-3.css" />
@@ -20,40 +16,86 @@
<body> <body>
<div id="app"> <div id="app">
<!-- 背景层:始终存在的磨砂玻璃质感 --> <!-- 背景层 -->
<div class="app-backdrop" :class="{ 'is-logged-in': loggedIn }"></div> <div class="app-backdrop" :class="{ 'is-logged-in': loggedIn }"></div>
<div class="loading-ring" :class="{ visible: transitioning }"></div> <div class="loading-ring" :class="{ visible: transitioning }"></div>
<!-- 主容器:登录态决定尺寸 --> <!-- 主容器 -->
<div class="app-container" :class="{ 'is-logged-in': loggedIn }"> <div class="app-container" :class="{ 'is-logged-in': loggedIn }">
<!-- ====== 登录页 ====== -->
<!-- ====== 登录/注册页 ====== -->
<div v-if="!loggedIn" class="login-card"> <div v-if="!loggedIn" class="login-card">
<div class="login-header">
<span class="login-icon">🚀</span> <!-- Logo 区域(可替换) -->
<h1>console login</h1> <div class="login-logo">
<svg viewBox="0 0 64 64" preserveAspectRatio="xMidYMid meet" style="width:100%; height:100%; display:block;" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="56" height="56" rx="16" stroke="#4a7cff" stroke-width="2" opacity="0.3"/>
<path d="M20 44L32 20L44 44H36L32 36L28 44H20Z" fill="#4a7cff" opacity="0.9"/>
<circle cx="32" cy="32" r="4" fill="#4a7cff" opacity="0.6"/>
<path d="M32 12V18M32 46V52M14 32H20M44 32H50M18 18L22 22M42 42L46 46M18 46L22 42M42 22L46 18" stroke="#4a7cff" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
</svg>
<span class="logo-text">frpc-console</span>
</div> </div>
<div class="login-form">
<div class="input-group"> <!-- 首次启动:注册页 -->
<label>user</label> <template v-if="isFirstRun">
<input v-model="loginForm.username" type="text" placeholder="用户名" @keydown.enter="doLogin" /> <div class="login-header">
<h1>首次设置</h1>
</div> </div>
<div class="input-group"> <div class="login-form">
<label>passwd</label> <div class="input-group">
<input v-model="loginForm.password" type="password" placeholder="密码" @keydown.enter="doLogin" /> <label>用户名(至少 5 位)</label>
<input v-model="registerForm.username" type="text" placeholder="设置管理员用户名" @keydown.enter="doRegister" />
</div>
<div class="input-group">
<label>密码</label>
<input v-model="registerForm.password" type="password" placeholder="至少8位,含大小写/数字/特殊字符" @keydown.enter="doRegister" />
</div>
<div class="input-group">
<label>确认密码</label>
<input v-model="registerForm.confirmPassword" type="password" placeholder="再次输入密码" @keydown.enter="doRegister" />
</div>
<button class="login-btn" @click="doRegister" :disabled="registering">
{{ registering ? '注册中...' : '注册' }}
</button>
<p v-if="registerError" class="login-error">{{ registerError }}</p>
</div> </div>
<button class="login-btn" @click="doLogin" :disabled="loading"> </template>
{{ loading ? '登录中...' : '登录' }}
</button> <!-- 正常:登录页 -->
<p v-if="loginError" class="login-error">{{ loginError }}</p> <template v-else>
</div> <div class="login-header">
<h1>console login</h1>
</div>
<div class="login-form">
<div class="input-group">
<label>user</label>
<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" />
</div>
<button class="login-btn" @click="doLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
</div>
</template>
</div> </div>
<!-- ====== 主界面(登录后) ====== --> <!-- ====== 主界面 ====== -->
<div v-else class="main-panel" :class="{ visible: contentVisible }"> <div v-else class="main-panel" :class="{ visible: contentVisible }">
<!-- 顶部导航 -->
<!-- 顶部导航(含 Logo 小标) -->
<div class="top-bar"> <div class="top-bar">
<div class="top-left"> <div class="top-left">
<span class="logo">🚀 frpc-console</span> <svg viewBox="0 0 28 28" preserveAspectRatio="xMidYMid meet" style="width:100%; height:100%; display:block;" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="#4a7cff" stroke-width="1.5" opacity="0.3"/>
<path d="M9 19L14 9L19 19H16L14 16L12 19H9Z" fill="#4a7cff" opacity="0.9"/>
<circle cx="14" cy="14" r="2" fill="#4a7cff" opacity="0.5"/>
</svg>
<span class="logo">frpc-console</span>
<span class="status-dot" :class="{ active: frpcRunning }"></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>
@@ -63,20 +105,44 @@
</div> </div>
</div> </div>
<!-- Tab 切换 --> <!-- Tab -->
<div class="tab-bar"> <div class="tab-bar">
<span class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'"> <span class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'">隧道列表</span>
隧道列表 <span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">全局配置信息</span>
</span>
<span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">
全局配置信息
</span>
</div> </div>
<!-- 内容区 --> <!-- 内容区 -->
<div class="content-area"> <div class="content-area">
<!-- 全局配置 -->
<!-- ====== 全局配置 ====== -->
<div v-if="activeTab === 'config'" class="tab-content"> <div v-if="activeTab === 'config'" class="tab-content">
<!-- 账户管理 -->
<div class="profile-section">
<div class="profile-header">
<span>👤 账户管理</span>
<span class="profile-username">{{ loginForm.username }}</span>
</div>
<div class="profile-form">
<div class="form-row">
<label>当前密码</label>
<input v-model="passwordChange.oldPassword" type="password" placeholder="输入当前密码" />
</div>
<div class="form-row">
<label>新密码</label>
<input v-model="passwordChange.newPassword" type="password" placeholder="至少8位,含大小写/数字/特殊字符" />
</div>
<div class="form-row">
<label>确认新密码</label>
<input v-model="passwordChange.confirmPassword" type="password" placeholder="再次输入新密码" />
</div>
<button class="save-btn" @click="changePassword">修改密码</button>
<p v-if="passwordChangeError" class="login-error">{{ passwordChangeError }}</p>
<p v-if="passwordChangeSuccess" class="login-success">{{ passwordChangeSuccess }}</p>
</div>
</div>
<!-- 配置卡片 -->
<div class="config-grid"> <div class="config-grid">
<div class="metric-card"> <div class="metric-card">
<div class="metric-label">TCP Multiplexing</div> <div class="metric-label">TCP Multiplexing</div>
@@ -106,74 +172,39 @@
<span>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span> <span>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span>
</div> </div>
<div v-show="showDetail" class="detail-panel"> <div v-show="showDetail" class="detail-panel">
<div class="form-row"> <div class="form-row"><label>Server Address</label><input v-model="globalConfig.serverAddr" /></div>
<label>Server Address</label> <div class="form-row"><label>Server Port</label><input type="number" v-model="globalConfig.serverPort" /></div>
<input v-model="globalConfig.serverAddr" /> <div class="form-row"><label>Token</label><input type="password" v-model="globalConfig.token" /></div>
</div>
<div class="form-row">
<label>Server Port</label>
<input type="number" v-model="globalConfig.serverPort" />
</div>
<div class="form-row">
<label>Token</label>
<input type="password" v-model="globalConfig.token" />
</div>
<div class="form-row"> <div class="form-row">
<label>Log Level</label> <label>Log Level</label>
<select v-model="globalConfig.logLevel"> <select v-model="globalConfig.logLevel">
<option value="trace">trace</option> <option value="trace">trace</option><option value="debug">debug</option>
<option value="debug">debug</option> <option value="info">info</option><option value="warn">warn</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option> <option value="error">error</option>
</select> </select>
</div> </div>
<div class="form-row"> <div class="form-row"><label>Log Max Days</label><input type="number" v-model="globalConfig.logMaxDays" /></div>
<label>Log Max Days</label> <div class="form-row"><label>TCP Mux Keepalive</label><input type="number" v-model="globalConfig.tcpMuxKeepalive" /></div>
<input type="number" v-model="globalConfig.logMaxDays" /> <div class="form-row"><label>Heartbeat Interval</label><input type="number" v-model="globalConfig.heartbeatInterval" /></div>
</div> <div class="form-row"><label>Heartbeat Timeout</label><input type="number" v-model="globalConfig.heartbeatTimeout" /></div>
<div class="form-row"> <div class="form-row"><label>Pool Count</label><input type="number" v-model="globalConfig.poolCount" /></div>
<label>TCP Mux Keepalive</label> <button class="save-btn" @click="saveConfig">保存配置并热加载</button>
<input type="number" v-model="globalConfig.tcpMuxKeepalive" />
</div>
<div class="form-row">
<label>Heartbeat Interval</label>
<input type="number" v-model="globalConfig.heartbeatInterval" />
</div>
<div class="form-row">
<label>Heartbeat Timeout</label>
<input type="number" v-model="globalConfig.heartbeatTimeout" />
</div>
<div class="form-row">
<label>Pool Count</label>
<input type="number" v-model="globalConfig.poolCount" />
</div>
<button class="save-btn" @click="saveConfig">
保存配置并热加载
</button>
</div> </div>
</div> </div>
<!-- 隧道列表 --> <!-- ====== 隧道列表 ====== -->
<div v-else-if="activeTab === 'proxies'" class="tab-content"> <div v-else-if="activeTab === 'proxies'" class="tab-content">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<button class="btn-import" @click="triggerImport"> <button class="btn-import" @click="triggerImport">导入 TOML</button>
导入 TOML <button class="btn-export" @click="exportToml">导出 TOML</button>
</button> <button class="btn-add" @click="openAddDialog">+ 新增隧道</button>
<button class="btn-export" @click="exportToml">
导出 TOML
</button>
<button class="btn-add" @click="openAddDialog">
+ 新增隧道
</button>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<input class="search-input" placeholder="搜索隧道..." v-model="searchKeyword" /> <input class="search-input" placeholder="搜索隧道..." v-model="searchKeyword" />
</div> </div>
</div> </div>
<!-- 隧道卡片列表 -->
<div class="proxy-list"> <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-row">
@@ -185,13 +216,8 @@
</div> </div>
<div class="proxy-right"> <div class="proxy-right">
<span class="remote-port">{{ p.remotePort }}</span> <span class="remote-port">{{ p.remotePort }}</span>
<button class="icon-btn" @click="openEditDialog(p)"> <button class="icon-btn" @click="openEditDialog(p)"></button>
<button class="icon-btn danger" @click="removeProxy(p.id)"></button>
</button>
<button class="icon-btn danger"
@click="(function(){ console.log('点击了删除按钮, id:', p.id); removeProxy(p.id); })()">
</button>
</div> </div>
</div> </div>
<div class="proxy-footer"> <div class="proxy-footer">
@@ -202,61 +228,43 @@
<span class="status-label">{{ p.enabled ? '已启用' : '已禁用' }}</span> <span class="status-label">{{ p.enabled ? '已启用' : '已禁用' }}</span>
</div> </div>
</div> </div>
<div v-if="filteredProxies.length === 0" class="empty-state"> <div v-if="filteredProxies.length === 0" class="empty-state">暂无隧道,点击「新增隧道」添加</div>
暂无隧道,点击「新增隧道」添加
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ====== 新增/编辑弹窗 ====== --> <!-- ====== 弹窗 ====== -->
<Transition name="dialog"> <Transition name="dialog">
<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"> <div class="dialog-card">
<h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3> <h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3>
<div class="dialog-form"> <div class="dialog-form">
<div class="form-row"> <div class="form-row"><label>名称</label><input v-model="dialogForm.name" /></div>
<label>名称</label>
<input v-model="dialogForm.name" />
</div>
<div class="form-row"> <div class="form-row">
<label>类型</label> <label>类型</label>
<select v-model="dialogForm.type"> <select v-model="dialogForm.type">
<option value="tcp">tcp</option> <option value="tcp">tcp</option><option value="udp">udp</option>
<option value="udp">udp</option> <option value="http">http</option><option value="https">https</option>
<option value="http">http</option>
<option value="https">https</option>
</select> </select>
</div> </div>
<div class="form-row"> <div class="form-row"><label>本地 IP</label><input v-model="dialogForm.localIP" /></div>
<label>本地 IP</label> <div class="form-row"><label>本地端口</label><input type="number" v-model="dialogForm.localPort" /></div>
<input v-model="dialogForm.localIP" /> <div class="form-row"><label>远程端口</label><input type="number" v-model="dialogForm.remotePort" /></div>
</div>
<div class="form-row">
<label>本地端口</label>
<input type="number" v-model="dialogForm.localPort" />
</div>
<div class="form-row">
<label>远程端口</label>
<input type="number" v-model="dialogForm.remotePort" />
</div>
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="btn-cancel" @click="dialogVisible = false"> <button class="btn-cancel" @click="dialogVisible = false">取消</button>
取消
</button>
<button class="btn-confirm" @click="confirmDialog">确认</button> <button class="btn-confirm" @click="confirmDialog">确认</button>
</div> </div>
</div> </div>
</div>
</Transition> </Transition>
<!-- ====== 隐藏文件输入(导入 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> </div>
<!-- 应用逻辑 -->
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
+20
View File
@@ -98,3 +98,23 @@
text-align: center; text-align: center;
margin-top: -6px; margin-top: -6px;
} }
/* ---------- Logo 区域 ---------- */
.login-logo {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.login-logo .logo-text {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 2px;
}
.login-logo svg {
width: 64px;
height: 64px;
flex-shrink: 0;
}
+72
View File
@@ -605,3 +605,75 @@ select:disabled {
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
opacity: 1; opacity: 1;
} }
/* ---------- 账户管理区域 ---------- */
.profile-section {
background: rgba(255, 255, 255, 0.02);
border-radius: 16px;
padding: 16px 20px;
border: 1px solid var(--border-subtle);
margin-bottom: 20px;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 14px;
}
.profile-username {
color: var(--primary-blue);
font-weight: 500;
}
.profile-form {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px 16px;
align-items: end;
}
.profile-form .form-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-form .form-row label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.profile-form .form-row input {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
}
.profile-form .form-row input:focus {
border-color: var(--primary-blue-border);
}
.profile-form .save-btn {
grid-column: auto;
padding: 8px 20px;
margin-top: 0;
height: 40px;
}
.login-success {
color: #63e2b7;
font-size: 14px;
text-align: center;
margin-top: 4px;
min-height: 24px;
}
.top-left svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}