加入首次使用的引导页、写入readme文件
This commit is contained in:
@@ -11,59 +11,126 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// SetupRouter 配置所有路由
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// 静态文件(前端)
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// 健康检查
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.String(200, "frpc-console 后端已启动 🎉")
|
||||
})
|
||||
|
||||
// 前端入口
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File("./static/index.html")
|
||||
})
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 登录(无需鉴权)
|
||||
// 公开接口
|
||||
api.GET("/check/users", checkUsersHandler)
|
||||
api.POST("/register", registerHandler)
|
||||
api.POST("/login", loginHandler)
|
||||
|
||||
// 需要鉴权的路由
|
||||
// 需鉴权
|
||||
auth := api.Group("/")
|
||||
auth.Use(AuthMiddleware())
|
||||
{
|
||||
// 全局配置
|
||||
auth.GET("/config", getConfigHandler)
|
||||
auth.PUT("/config", updateConfigHandler)
|
||||
|
||||
// 隧道管理
|
||||
auth.GET("/proxies", getProxiesHandler)
|
||||
auth.GET("/proxy/:id", getProxyHandler)
|
||||
auth.POST("/proxy", createProxyHandler)
|
||||
auth.PUT("/proxy/:id", updateProxyHandler)
|
||||
auth.DELETE("/proxy/:id", deleteProxyHandler)
|
||||
|
||||
// frpc 控制
|
||||
auth.POST("/frpc/reload", reloadFrpcHandler)
|
||||
auth.POST("/frpc/start", startFrpcHandler)
|
||||
auth.POST("/frpc/stop", stopFrpcHandler)
|
||||
auth.GET("/frpc/status", getFrpcStatusHandler)
|
||||
|
||||
// 🟢 导入 TOML(新增)
|
||||
auth.POST("/import/toml", importTomlHandler)
|
||||
auth.GET("/export/toml", ExportTomlHandler)
|
||||
|
||||
auth.PUT("/user/password", changePasswordHandler)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
cfg, err := GetGlobalConfig()
|
||||
@@ -115,8 +232,6 @@ func updateConfigHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 强制 tcpMux 为 true
|
||||
cfg.TcpMux = true
|
||||
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
var p Proxy
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
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}})
|
||||
}
|
||||
|
||||
// ========== 🟢 导入 TOML(新增) ==========
|
||||
// ========== 导入 TOML ==========
|
||||
func importTomlHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -294,20 +408,13 @@ func importTomlHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 更新全局配置(保持当前 tcpMux 值)
|
||||
cfg := parsed.ToGlobalConfig()
|
||||
currentCfg, _ := GetGlobalConfig()
|
||||
if currentCfg != nil {
|
||||
cfg.TcpMux = currentCfg.TcpMux // 保持当前值不变
|
||||
}
|
||||
cfg.TcpMux = true // 强制开启
|
||||
|
||||
cfg.TcpMux = true
|
||||
if err := UpdateGlobalConfig(cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 清空并导入隧道
|
||||
if _, err := DB.Exec("DELETE FROM proxies"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "清空隧道失败"})
|
||||
return
|
||||
@@ -321,13 +428,11 @@ func importTomlHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成配置并热加载
|
||||
if err := GenerateFrpcConfig(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ReloadFrpc(); err != nil {
|
||||
// 导入成功但热加载失败,不影响整体结果
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"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) {
|
||||
cfg, err := GetGlobalConfig()
|
||||
if err != nil {
|
||||
@@ -370,7 +475,6 @@ func ExportTomlHandler(c *gin.Context) {
|
||||
Proxies: activeProxies,
|
||||
}
|
||||
|
||||
// 解析模板(直接使用 frp.go 中的 frpcTemplateContent)
|
||||
tmpl, err := template.New("frpc").Parse(FrpcTemplateContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()})
|
||||
@@ -383,13 +487,12 @@ func ExportTomlHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 返回文件下载
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=frpc.toml")
|
||||
c.String(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
// ========== 辅助 ==========
|
||||
func generateAndReload() error {
|
||||
if err := GenerateFrpcConfig(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -13,12 +13,18 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var jwtSecretCache []byte
|
||||
|
||||
func getJwtSecret() []byte {
|
||||
if len(jwtSecretCache) > 0 {
|
||||
return jwtSecretCache
|
||||
}
|
||||
secret, err := GetJwtSecret()
|
||||
if err != nil {
|
||||
log.Fatal("❌ 获取 JWT 密钥失败:", err)
|
||||
}
|
||||
return []byte(secret)
|
||||
jwtSecretCache = []byte(secret)
|
||||
return jwtSecretCache
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
@@ -51,49 +57,45 @@ func ParseJWT(tokenString string) (*Claims, error) {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// 密码复杂度验证:至少8位密码,包含大写字母、数字和特殊字符
|
||||
func ValidatePassword(pwd string) bool {
|
||||
if len(pwd) < 8 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
||||
for _, ch := range pwd {
|
||||
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
|
||||
hasLetter = true
|
||||
}
|
||||
if ch >= '0' && ch <= '9' {
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
hasUpper = true
|
||||
} else if ch >= 'a' && ch <= 'z' {
|
||||
hasLower = true
|
||||
} else if ch >= '0' && ch <= '9' {
|
||||
hasDigit = true
|
||||
} else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") {
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit
|
||||
return hasUpper && hasLower && hasDigit && hasSpecial
|
||||
}
|
||||
|
||||
// 首次启动强制设置管理员账户
|
||||
func InitAdminUser() {
|
||||
count, err := CountUsers()
|
||||
if err != nil {
|
||||
log.Fatal("❌ 检查用户表失败:", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log.Println("✅ 已存在管理员账户,跳过初始化")
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("⚠️ 首次启动,请设置管理员账户")
|
||||
log.Print("用户名: ")
|
||||
var username string
|
||||
fmt.Scanln(&username)
|
||||
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
for {
|
||||
log.Print("密码 (至少8位密码,包含大写字母、数字和特殊字符): ")
|
||||
log.Print("密码 (至少8位,含大小写、数字、特殊字符): ")
|
||||
var password string
|
||||
fmt.Scanln(&password)
|
||||
|
||||
if ValidatePassword(password) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -109,12 +111,11 @@ func InitAdminUser() {
|
||||
break
|
||||
} else {
|
||||
log.Println("❌ 密码不符合复杂度要求,请重新输入")
|
||||
log.Println(" 要求: 至少8位密码,包含大写字母、数字和特殊字符")
|
||||
log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JWT 认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -123,21 +124,18 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 格式 "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tokenString := parts[1]
|
||||
claims, err := ParseJWT(tokenString)
|
||||
claims, err := ParseJWT(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 将用户名存入上下文,方便后续使用
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
var dbInitOnce sync.Once
|
||||
|
||||
// ===== 全局配置表(A表)=====
|
||||
type GlobalConfig struct {
|
||||
@@ -90,7 +88,6 @@ func InitDB() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确保全局配置有一行数据
|
||||
var count int
|
||||
DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count)
|
||||
if count == 0 {
|
||||
@@ -124,7 +121,7 @@ func InitDB() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 🟢 新增:应用配置表(用于存储 JWT 密钥等全局配置)
|
||||
// 应用配置表(JWT密钥等)
|
||||
_, err = DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -136,7 +133,6 @@ func InitDB() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 🟢 确保 JWT 密钥存在
|
||||
if err := ensureJwtSecret(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -145,25 +141,20 @@ func InitDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== 🟢 新增:JWT 密钥管理 =====
|
||||
|
||||
// ensureJwtSecret 检查 app_config 表里是否有 jwt_secret,没有则生成一个
|
||||
// ===== JWT 密钥管理 =====
|
||||
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)
|
||||
@@ -174,7 +165,6 @@ func ensureJwtSecret() error {
|
||||
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)
|
||||
@@ -199,17 +189,15 @@ func GetGlobalConfig() (*GlobalConfig, error) {
|
||||
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 = 1, -- 🟢 强制写死为 1
|
||||
tcp_mux = 1,
|
||||
tcp_mux_keepalive = ?, heartbeat_interval = ?, heartbeat_timeout = ?, pool_count = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
@@ -238,12 +226,9 @@ func GetProxies() ([]Proxy, error) {
|
||||
}
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
|
||||
// 🟢 关键:检查遍历过程中是否有错误
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
@@ -307,6 +292,14 @@ func CreateUser(username, passwordHash string) error {
|
||||
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) {
|
||||
var count int
|
||||
err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
|
||||
Binary file not shown.
@@ -19,80 +19,3 @@ poolCount = 8
|
||||
[webServer]
|
||||
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
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# 🚀 frpc-console
|
||||
|
||||
> 轻量级 frpc 管理面板 —— 为你的内网穿透插上翅膀
|
||||
|
||||
[](https://golang.org/)
|
||||
[](LICENSE)
|
||||
[](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 镜像支持
|
||||
|
||||
---
|
||||
|
||||
|
||||
+182
-77
@@ -1,12 +1,8 @@
|
||||
// app.js - 普通脚本(非模块)
|
||||
|
||||
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||
|
||||
// ============================================================
|
||||
// 1. 把 modules/*.js 的内容直接合并进来(因为是普通脚本,不能 import)
|
||||
// API
|
||||
// ============================================================
|
||||
|
||||
// ---------- api ----------
|
||||
const API_BASE = "/api";
|
||||
function getToken() {
|
||||
return localStorage.getItem("frpc_token") || "";
|
||||
@@ -19,8 +15,7 @@ function getHeaders() {
|
||||
}
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const url = API_BASE + endpoint;
|
||||
const headers =
|
||||
options.body instanceof FormData
|
||||
const headers = options.body instanceof FormData
|
||||
? { Authorization: "Bearer " + getToken() }
|
||||
: getHeaders();
|
||||
const res = await fetch(url, {
|
||||
@@ -30,7 +25,9 @@ async function apiFetch(endpoint, options = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
// ============================================================
|
||||
// Auth
|
||||
// ============================================================
|
||||
function parseJwtExpire(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
@@ -74,7 +71,9 @@ async function login(username, password) {
|
||||
return { success: false, error: data.msg || "登录失败" };
|
||||
}
|
||||
|
||||
// ---------- config ----------
|
||||
// ============================================================
|
||||
// Config
|
||||
// ============================================================
|
||||
const defaultConfig = {
|
||||
serverAddr: "frp.whitetop.xyz",
|
||||
serverPort: 9358,
|
||||
@@ -106,7 +105,9 @@ async function saveConfigApi(config) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- proxies ----------
|
||||
// ============================================================
|
||||
// Proxies
|
||||
// ============================================================
|
||||
async function loadProxies() {
|
||||
try {
|
||||
const data = await apiFetch("/proxies");
|
||||
@@ -141,15 +142,16 @@ async function deleteProxy(id) {
|
||||
function filterProxies(list, keyword) {
|
||||
if (!keyword) return list;
|
||||
const kw = keyword.toLowerCase();
|
||||
return list.filter(
|
||||
(p) =>
|
||||
return list.filter(p =>
|
||||
p.name.toLowerCase().includes(kw) ||
|
||||
p.localIP.includes(kw) ||
|
||||
String(p.remotePort).includes(kw),
|
||||
String(p.remotePort).includes(kw)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- frpc ----------
|
||||
// ============================================================
|
||||
// Frpc
|
||||
// ============================================================
|
||||
async function getFrpcStatus() {
|
||||
try {
|
||||
const data = await apiFetch("/frpc/status");
|
||||
@@ -161,27 +163,11 @@ async function getFrpcStatus() {
|
||||
}
|
||||
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");
|
||||
function switchTab(tab) {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
// 弹窗控制
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("add");
|
||||
const dialogForm = ref({
|
||||
@@ -192,17 +178,11 @@ const dialogForm = ref({
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
});
|
||||
const searchKeyword = ref("");
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = "add";
|
||||
dialogForm.value = {
|
||||
id: null,
|
||||
name: "",
|
||||
type: "tcp",
|
||||
localIP: "",
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
};
|
||||
dialogForm.value = { id: null, name: "", type: "tcp", localIP: "", localPort: 0, remotePort: 0 };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function openEditDialog(proxy) {
|
||||
@@ -214,9 +194,9 @@ function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
const searchKeyword = ref("");
|
||||
|
||||
// ---------- import/export 功能 ----------
|
||||
// ============================================================
|
||||
// Import / Export
|
||||
// ============================================================
|
||||
async function triggerImport() {
|
||||
document.getElementById("tomlFileInput").click();
|
||||
}
|
||||
@@ -244,42 +224,35 @@ async function handleImport(e) {
|
||||
e.target.value = "";
|
||||
}
|
||||
async function exportToml() {
|
||||
console.log('🔍 exportToml 被调用了');
|
||||
try {
|
||||
const res = await fetch('/api/export/toml', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
const res = await fetch("/api/export/toml", {
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer " + getToken() },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert('导出失败: ' + (data.msg || '未知错误'));
|
||||
alert("导出失败: " + (data.msg || "未知错误"));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'frpc.toml';
|
||||
a.download = "frpc.toml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
|
||||
console.log('✅ 导出成功');
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e);
|
||||
alert('导出失败: ' + e.message);
|
||||
alert("导出失败: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Vue 应用
|
||||
// Vue App
|
||||
// ============================================================
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
// ===== 登录态 =====
|
||||
// ---- 登录态 ----
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const loginError = ref("");
|
||||
@@ -290,13 +263,32 @@ const app = createApp({
|
||||
const contentVisible = ref(false);
|
||||
let expireTimer = null;
|
||||
|
||||
// ===== 数据 =====
|
||||
// ---- 注册 ----
|
||||
const isFirstRun = ref(false);
|
||||
const registerForm = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const registerError = ref("");
|
||||
const registering = ref(false);
|
||||
|
||||
// ---- 数据 ----
|
||||
const globalConfig = reactive({});
|
||||
const proxies = ref([]);
|
||||
const frpcRunning = ref(false);
|
||||
const showDetail = ref(false);
|
||||
|
||||
// ===== 初始化 =====
|
||||
// ---- 修改密码 ----
|
||||
const passwordChange = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const passwordChangeError = ref("");
|
||||
const passwordChangeSuccess = ref("");
|
||||
|
||||
// ---- 初始化 ----
|
||||
onMounted(() => {
|
||||
const saved = loadAuthState();
|
||||
if (saved.valid) {
|
||||
@@ -308,10 +300,24 @@ const app = createApp({
|
||||
contentVisible.value = true;
|
||||
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);
|
||||
expireTimer = setInterval(() => {
|
||||
if (Date.now() >= tokenExpireTime.value) {
|
||||
@@ -319,9 +325,9 @@ const app = createApp({
|
||||
alert("登录已超时,请重新登录");
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
const loadAllData = async () => {
|
||||
async function loadAllData() {
|
||||
const [cfg, list, running] = await Promise.all([
|
||||
loadConfig(),
|
||||
loadProxies(),
|
||||
@@ -330,9 +336,9 @@ const app = createApp({
|
||||
Object.assign(globalConfig, cfg);
|
||||
proxies.value = list;
|
||||
frpcRunning.value = running;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 登录/退出 =====
|
||||
// ---- 登录 ----
|
||||
const doLogin = async () => {
|
||||
if (!loginForm.username || !loginForm.password) {
|
||||
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 = () => {
|
||||
if (expireTimer) clearInterval(expireTimer);
|
||||
contentVisible.value = false;
|
||||
@@ -374,7 +432,42 @@ const app = createApp({
|
||||
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 result = await saveConfigApi(globalConfig);
|
||||
if (result.code === 0) {
|
||||
@@ -385,7 +478,7 @@ const app = createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 隧道操作 =====
|
||||
// ---- 隧道操作 ----
|
||||
const toggleProxy = async (p) => {
|
||||
const result = await updateProxy(p);
|
||||
if (result.code !== 0) {
|
||||
@@ -395,9 +488,7 @@ const app = createApp({
|
||||
frpcRunning.value = await getFrpcStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const removeProxy = async (id) => {
|
||||
console.log("🔍 removeProxy 被调用, id:", id); // ← 加这行
|
||||
if (!confirm("确定要删除这条隧道吗?")) return;
|
||||
const result = await deleteProxy(id);
|
||||
if (result.code === 0) {
|
||||
@@ -407,15 +498,13 @@ const app = createApp({
|
||||
alert("删除失败: " + result.msg);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDialog = async () => {
|
||||
const form = dialogForm.value;
|
||||
if (!form.name || !form.localIP || !form.localPort || !form.remotePort) {
|
||||
alert("请填写完整信息");
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
dialogMode.value === "add"
|
||||
const result = dialogMode.value === "add"
|
||||
? await createProxy(form)
|
||||
: await updateProxy(form);
|
||||
if (result.code === 0) {
|
||||
@@ -427,11 +516,12 @@ const app = createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 计算属性 =====
|
||||
// ---- 计算属性 ----
|
||||
const filteredProxies = computed(() =>
|
||||
filterProxies(proxies.value, searchKeyword.value),
|
||||
filterProxies(proxies.value, searchKeyword.value)
|
||||
);
|
||||
|
||||
// ---- 返回 ----
|
||||
return {
|
||||
loggedIn,
|
||||
loading,
|
||||
@@ -439,16 +529,24 @@ const app = createApp({
|
||||
loginForm,
|
||||
doLogin,
|
||||
doLogout,
|
||||
|
||||
isFirstRun,
|
||||
registerForm,
|
||||
registerError,
|
||||
registering,
|
||||
doRegister,
|
||||
|
||||
activeTab,
|
||||
switchTab,
|
||||
globalConfig,
|
||||
showDetail,
|
||||
saveConfig,
|
||||
|
||||
proxies,
|
||||
filteredProxies,
|
||||
toggleProxy,
|
||||
removeProxy,
|
||||
frpcRunning,
|
||||
|
||||
dialogVisible,
|
||||
dialogMode,
|
||||
dialogForm,
|
||||
@@ -456,12 +554,19 @@ const app = createApp({
|
||||
openEditDialog,
|
||||
closeDialog,
|
||||
confirmDialog,
|
||||
|
||||
searchKeyword,
|
||||
transitioning,
|
||||
contentVisible,
|
||||
|
||||
triggerImport,
|
||||
handleImport,
|
||||
exportToml,
|
||||
|
||||
passwordChange,
|
||||
passwordChangeError,
|
||||
passwordChangeSuccess,
|
||||
changePassword,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+115
-107
@@ -8,11 +8,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" />
|
||||
<!-- 不再需要前端 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-2.css" />
|
||||
<link rel="stylesheet" href="/static/style-3.css" />
|
||||
@@ -20,16 +16,55 @@
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 背景层:始终存在的磨砂玻璃质感 -->
|
||||
<!-- 背景层 -->
|
||||
<div class="app-backdrop" :class="{ 'is-logged-in': loggedIn }"></div>
|
||||
<div class="loading-ring" :class="{ visible: transitioning }"></div>
|
||||
|
||||
<!-- 主容器:登录态决定尺寸 -->
|
||||
<!-- 主容器 -->
|
||||
<div class="app-container" :class="{ 'is-logged-in': loggedIn }">
|
||||
<!-- ====== 登录页 ====== -->
|
||||
|
||||
<!-- ====== 登录/注册页 ====== -->
|
||||
<div v-if="!loggedIn" class="login-card">
|
||||
|
||||
<!-- Logo 区域(可替换) -->
|
||||
<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>
|
||||
|
||||
<!-- 首次启动:注册页 -->
|
||||
<template v-if="isFirstRun">
|
||||
<div class="login-header">
|
||||
<h1>首次设置</h1>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- 正常:登录页 -->
|
||||
<template v-else>
|
||||
<div class="login-header">
|
||||
<span class="login-icon">🚀</span>
|
||||
<h1>console login</h1>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
@@ -46,14 +81,21 @@
|
||||
</button>
|
||||
<p v-if="loginError" class="login-error">{{ loginError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ====== 主界面(登录后) ====== -->
|
||||
<!-- ====== 主界面 ====== -->
|
||||
<div v-else class="main-panel" :class="{ visible: contentVisible }">
|
||||
<!-- 顶部导航 -->
|
||||
|
||||
<!-- 顶部导航(含 Logo 小标) -->
|
||||
<div class="top-bar">
|
||||
<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-text">{{ frpcRunning ? 'frpc 运行中' : 'frpc 已停止' }}</span>
|
||||
</div>
|
||||
@@ -63,20 +105,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<!-- Tab 栏 -->
|
||||
<div class="tab-bar">
|
||||
<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 class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'">隧道列表</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">全局配置信息</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="content-area">
|
||||
<!-- 全局配置 -->
|
||||
|
||||
<!-- ====== 全局配置 ====== -->
|
||||
<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="metric-card">
|
||||
<div class="metric-label">TCP Multiplexing</div>
|
||||
@@ -106,74 +172,39 @@
|
||||
<span>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span>
|
||||
</div>
|
||||
<div v-show="showDetail" class="detail-panel">
|
||||
<div class="form-row">
|
||||
<label>Server Address</label>
|
||||
<input v-model="globalConfig.serverAddr" />
|
||||
</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"><label>Server Address</label><input v-model="globalConfig.serverAddr" /></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">
|
||||
<label>Log Level</label>
|
||||
<select v-model="globalConfig.logLevel">
|
||||
<option value="trace">trace</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="trace">trace</option><option value="debug">debug</option>
|
||||
<option value="info">info</option><option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Log Max Days</label>
|
||||
<input type="number" v-model="globalConfig.logMaxDays" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>TCP Mux Keepalive</label>
|
||||
<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 class="form-row"><label>Log Max Days</label><input type="number" v-model="globalConfig.logMaxDays" /></div>
|
||||
<div class="form-row"><label>TCP Mux Keepalive</label><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 v-else-if="activeTab === 'proxies'" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="btn-import" @click="triggerImport">
|
||||
导入 TOML
|
||||
</button>
|
||||
<button class="btn-export" @click="exportToml">
|
||||
导出 TOML
|
||||
</button>
|
||||
<button class="btn-add" @click="openAddDialog">
|
||||
+ 新增隧道
|
||||
</button>
|
||||
<button class="btn-import" @click="triggerImport">导入 TOML</button>
|
||||
<button class="btn-export" @click="exportToml">导出 TOML</button>
|
||||
<button class="btn-add" @click="openAddDialog">+ 新增隧道</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<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 class="proxy-row">
|
||||
@@ -185,13 +216,8 @@
|
||||
</div>
|
||||
<div class="proxy-right">
|
||||
<span class="remote-port">{{ p.remotePort }}</span>
|
||||
<button class="icon-btn" @click="openEditDialog(p)">
|
||||
✎
|
||||
</button>
|
||||
<button class="icon-btn danger"
|
||||
@click="(function(){ console.log('点击了删除按钮, id:', p.id); removeProxy(p.id); })()">
|
||||
✕
|
||||
</button>
|
||||
<button class="icon-btn" @click="openEditDialog(p)">✎</button>
|
||||
<button class="icon-btn danger" @click="removeProxy(p.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-footer">
|
||||
@@ -202,61 +228,43 @@
|
||||
<span class="status-label">{{ p.enabled ? '已启用' : '已禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredProxies.length === 0" class="empty-state">
|
||||
暂无隧道,点击「新增隧道」添加
|
||||
</div>
|
||||
<div v-if="filteredProxies.length === 0" class="empty-state">暂无隧道,点击「新增隧道」添加</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 新增/编辑弹窗 ====== -->
|
||||
<!-- ====== 弹窗 ====== -->
|
||||
<Transition name="dialog">
|
||||
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
|
||||
<div class="dialog-card">
|
||||
<h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3>
|
||||
<div class="dialog-form">
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<input v-model="dialogForm.name" />
|
||||
</div>
|
||||
<div class="form-row"><label>名称</label><input v-model="dialogForm.name" /></div>
|
||||
<div class="form-row">
|
||||
<label>类型</label>
|
||||
<select v-model="dialogForm.type">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
<option value="tcp">tcp</option><option value="udp">udp</option>
|
||||
<option value="http">http</option><option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>本地 IP</label>
|
||||
<input v-model="dialogForm.localIP" />
|
||||
</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 class="form-row"><label>本地 IP</label><input v-model="dialogForm.localIP" /></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 class="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn-cancel" @click="dialogVisible = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmDialog">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 应用逻辑 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -98,3 +98,23 @@
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
@@ -605,3 +605,75 @@ select:disabled {
|
||||
transform: scale(1) translateY(0);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user