首次推送
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs" // 新增
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
staticSubFS, _ := fs.Sub(staticFS, "static")
|
||||
r.StaticFS("/static", http.FS(staticSubFS))
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
content, err := staticFS.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
c.String(500, "加载前端页面失败")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
})
|
||||
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.String(200, "frps-console 后端已启动 🎉")
|
||||
})
|
||||
|
||||
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("/frps/log", getFrpsLogHandler)
|
||||
|
||||
// frps 特有:客户端列表(从 Dashboard API 读取)
|
||||
auth.GET("/clients", getClientsHandler)
|
||||
auth.GET("/proxies", getProxiesHandler)
|
||||
|
||||
auth.POST("/frps/reload", reloadFrpsHandler)
|
||||
auth.POST("/frps/start", startFrpsHandler)
|
||||
auth.POST("/frps/stop", stopFrpsHandler)
|
||||
auth.GET("/frps/status", getFrpsStatusHandler)
|
||||
|
||||
auth.POST("/import/toml", importTomlHandler)
|
||||
auth.GET("/export/toml", ExportTomlHandler)
|
||||
|
||||
auth.PUT("/user/password", changePasswordHandler)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 认证 Handler(与 frpc-console 相同,略)
|
||||
// ============================================================
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
user, err := GetUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := GenerateJWT(user.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 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": "密码修改成功"})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 配置 Handler
|
||||
// ============================================================
|
||||
|
||||
func getConfigHandler(c *gin.Context) {
|
||||
cfg, err := GetGlobalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": cfg})
|
||||
}
|
||||
|
||||
func updateConfigHandler(c *gin.Context) {
|
||||
var cfg GlobalConfig
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := UpdateGlobalConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := GenerateFrpsConfig(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ReloadFrps(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 客户端/代理列表(从 frps Dashboard API 读取)
|
||||
// ============================================================
|
||||
|
||||
// FrpsDashboardClient frps Dashboard API 返回的客户端信息
|
||||
type FrpsDashboardClient struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
ConnTime string `json:"connTime"`
|
||||
}
|
||||
|
||||
// FrpsDashboardProxy frps Dashboard API 返回的代理信息
|
||||
type FrpsDashboardProxy struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
LocalAddr string `json:"localAddr"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
}
|
||||
|
||||
func getClientsHandler(c *gin.Context) {
|
||||
clients, err := fetchClientsFromDashboard()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": clients})
|
||||
}
|
||||
|
||||
func getProxiesHandler(c *gin.Context) {
|
||||
proxies, err := fetchProxiesFromDashboard()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": proxies})
|
||||
}
|
||||
|
||||
// fetchClientsFromDashboard 从 frps Dashboard API 读取客户端列表
|
||||
func fetchClientsFromDashboard() ([]FrpsDashboardClient, error) {
|
||||
// frps Dashboard 默认在 127.0.0.1:7500,后续可配置
|
||||
resp, err := http.Get("http://127.0.0.1:7500/api/v2/clients")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Data []FrpsDashboardClient `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// fetchProxiesFromDashboard 从 frps Dashboard API 读取代理列表
|
||||
func fetchProxiesFromDashboard() ([]FrpsDashboardProxy, error) {
|
||||
resp, err := http.Get("http://127.0.0.1:7500/api/v2/proxies")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Data []FrpsDashboardProxy `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// frps 进程管理 Handler
|
||||
// ============================================================
|
||||
|
||||
func reloadFrpsHandler(c *gin.Context) {
|
||||
if err := GenerateFrpsConfig(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ReloadFrps(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "热加载成功"})
|
||||
}
|
||||
|
||||
func startFrpsHandler(c *gin.Context) {
|
||||
if err := StartFrps(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "启动失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 启动成功"})
|
||||
}
|
||||
|
||||
func stopFrpsHandler(c *gin.Context) {
|
||||
if err := StopFrps(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "停止失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 已停止"})
|
||||
}
|
||||
|
||||
func getFrpsStatusHandler(c *gin.Context) {
|
||||
running, err := GetFrpsStatus()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询状态失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导入/导出 TOML
|
||||
// ============================================================
|
||||
|
||||
func importTomlHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请选择文件"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(f); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := ParseToml(buf.String())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "解析 TOML 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cfg := parsed.ToGlobalConfig()
|
||||
if err := UpdateGlobalConfig(cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := GenerateFrpsConfig(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ReloadFrps(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "导入成功,但热加载失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "导入成功"})
|
||||
}
|
||||
|
||||
func ExportTomlHandler(c *gin.Context) {
|
||||
cfg, err := GetGlobalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.New("frps").Parse(FrpsTemplateContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "渲染模板失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=frps.toml")
|
||||
c.String(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日志读取
|
||||
// ============================================================
|
||||
|
||||
func getFrpsLogHandler(c *gin.Context) {
|
||||
lines, err := readTailLog("./frps.log", 200)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"lines": []string{},
|
||||
"total": 0,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"lines": lines,
|
||||
"total": len(lines),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// readTailLog 读取文件末尾 n 行
|
||||
func readTailLog(filePath string, n int) ([]string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := info.Size()
|
||||
if fileSize == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
const chunkSize = 4096
|
||||
var lines []string
|
||||
var leftover []byte
|
||||
offset := fileSize
|
||||
|
||||
for len(lines) < n && offset > 0 {
|
||||
readSize := chunkSize
|
||||
if offset < int64(chunkSize) {
|
||||
readSize = int(offset)
|
||||
}
|
||||
offset -= int64(readSize)
|
||||
|
||||
buf := make([]byte, readSize)
|
||||
_, err := file.ReadAt(buf, offset)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := append(buf, leftover...)
|
||||
leftover = nil
|
||||
|
||||
start := 0
|
||||
for i := len(data) - 1; i >= 0; i-- {
|
||||
if data[i] == '\n' {
|
||||
if i+1 < len(data) {
|
||||
line := string(data[i+1:])
|
||||
if line != "" {
|
||||
lines = append([]string{line}, lines...)
|
||||
if len(lines) >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) < n && start > 0 {
|
||||
leftover = data[:start]
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) < n && len(leftover) > 0 {
|
||||
parts := strings.Split(string(leftover), "\n")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if parts[i] != "" {
|
||||
lines = append([]string{parts[i]}, lines...)
|
||||
if len(lines) >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"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)
|
||||
}
|
||||
jwtSecretCache = []byte(secret)
|
||||
return jwtSecretCache
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateJWT(username string) (string, error) {
|
||||
claims := Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(getJwtSecret())
|
||||
}
|
||||
|
||||
func ParseJWT(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return getJwtSecret(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
func ValidatePassword(pwd string) bool {
|
||||
if len(pwd) < 8 {
|
||||
return false
|
||||
}
|
||||
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
||||
for _, ch := range pwd {
|
||||
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 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位,含大小写、数字、特殊字符): ")
|
||||
var password string
|
||||
fmt.Scanln(&password)
|
||||
if ValidatePassword(password) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Println("❌ 密码加密失败:", err)
|
||||
continue
|
||||
}
|
||||
err = CreateUser(username, string(hash))
|
||||
if err != nil {
|
||||
log.Println("❌ 创建用户失败:", err)
|
||||
continue
|
||||
}
|
||||
log.Println("✅ 管理员账户创建成功!")
|
||||
break
|
||||
} else {
|
||||
log.Println("❌ 密码不符合复杂度要求,请重新输入")
|
||||
log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未提供认证令牌"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,410 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// ============================================================
|
||||
// 数据库版本常量
|
||||
// ============================================================
|
||||
|
||||
const SchemaVersion = "2.0.0"
|
||||
|
||||
// ============================================================
|
||||
// 数据模型
|
||||
// ============================================================
|
||||
|
||||
// GlobalConfig frps 全局配置
|
||||
type GlobalConfig struct {
|
||||
ID int `json:"id"`
|
||||
BindPort int `json:"bindPort"`
|
||||
Token string `json:"token"`
|
||||
LogLevel string `json:"logLevel"`
|
||||
LogMaxDays int `json:"logMaxDays"`
|
||||
TcpMux bool `json:"tcpMux"`
|
||||
}
|
||||
|
||||
// Client 客户端连接记录
|
||||
type Client struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
ConnTime string `json:"connTime"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// User 用户表
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 数据库初始化
|
||||
// ============================================================
|
||||
|
||||
func InitDB() error {
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", "./frps-console.db")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createTables(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := runMigrations(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureJwtSecret(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("✅ 数据库初始化完成 (Schema v" + SchemaVersion + ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 建表
|
||||
// ============================================================
|
||||
|
||||
func createTables() error {
|
||||
// 用户表
|
||||
_, err := DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 全局配置表(frps 专用)
|
||||
_, err = DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS global_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
bind_port INTEGER NOT NULL DEFAULT 9358,
|
||||
token TEXT NOT NULL DEFAULT 'CHANGE_ME',
|
||||
log_level TEXT NOT NULL DEFAULT 'info',
|
||||
log_max_days INTEGER NOT NULL DEFAULT 3,
|
||||
tcp_mux INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 客户端记录表
|
||||
_, err = DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
os TEXT,
|
||||
arch TEXT,
|
||||
version TEXT,
|
||||
status TEXT,
|
||||
conn_time DATETIME,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 应用配置表
|
||||
_, err = DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化默认配置
|
||||
var count int
|
||||
DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count)
|
||||
if count == 0 {
|
||||
_, err = DB.Exec(`
|
||||
INSERT INTO global_config (
|
||||
id, bind_port, token, log_level, log_max_days, tcp_mux
|
||||
) VALUES (1, 9358, 'CHANGE_ME', 'info', 3, 1)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("✅ 全局配置初始化完成")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移引擎
|
||||
// ============================================================
|
||||
|
||||
func getSchemaVersion() string {
|
||||
var version string
|
||||
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'schema_version'").Scan(&version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
var count int
|
||||
DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if count > 0 {
|
||||
return "1.0.0"
|
||||
}
|
||||
return SchemaVersion
|
||||
}
|
||||
return "1.0.0"
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func setSchemaVersion(version string) error {
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO app_config (key, value) VALUES ('schema_version', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
`, version)
|
||||
return err
|
||||
}
|
||||
|
||||
func backupDatabase() (string, error) {
|
||||
src := "./frps-console.db"
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
dst := fmt.Sprintf("./frps-console.db.pre-v%s.%s", SchemaVersion, timestamp)
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开源数据库失败: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建备份文件失败: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return "", fmt.Errorf("复制数据库失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ 数据库备份完成: %s", dst)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func restoreDatabase(backupPath string) error {
|
||||
srcFile, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create("./frps-console.db")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("✅ 数据库已从备份恢复: %s", backupPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
currentVersion := getSchemaVersion()
|
||||
log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVersion, SchemaVersion)
|
||||
|
||||
if currentVersion != SchemaVersion {
|
||||
log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVersion, SchemaVersion)
|
||||
|
||||
backupPath, err := backupDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("备份数据库失败: %w", err)
|
||||
}
|
||||
if backupPath != "" {
|
||||
log.Printf("📦 备份文件: %s", backupPath)
|
||||
}
|
||||
|
||||
if err := setSchemaVersion(SchemaVersion); err != nil {
|
||||
return fmt.Errorf("更新 Schema 版本失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ 版本迁移完成")
|
||||
} else {
|
||||
log.Println("✅ Schema 已是最新,无需迁移")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return err
|
||||
}
|
||||
secret := hex.EncodeToString(bytes)
|
||||
|
||||
_, err = DB.Exec(`
|
||||
INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?)
|
||||
`, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("✅ JWT 密钥已生成")
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetJwtSecret() (string, error) {
|
||||
var secret string
|
||||
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全局配置 CRUD
|
||||
// ============================================================
|
||||
|
||||
func GetGlobalConfig() (*GlobalConfig, error) {
|
||||
var cfg GlobalConfig
|
||||
err := DB.QueryRow(`
|
||||
SELECT id, bind_port, token, log_level, log_max_days, tcp_mux
|
||||
FROM global_config WHERE id = 1
|
||||
`).Scan(
|
||||
&cfg.ID, &cfg.BindPort, &cfg.Token,
|
||||
&cfg.LogLevel, &cfg.LogMaxDays,
|
||||
&cfg.TcpMux,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.TcpMux = true
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func UpdateGlobalConfig(cfg *GlobalConfig) error {
|
||||
_, err := DB.Exec(`
|
||||
UPDATE global_config SET
|
||||
bind_port = ?, token = ?, log_level = ?, log_max_days = ?,
|
||||
tcp_mux = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`, cfg.BindPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 客户端记录 CRUD
|
||||
// ============================================================
|
||||
|
||||
func SaveClient(client *Client) error {
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO clients (name, os, arch, version, status, conn_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, client.Name, client.OS, client.Arch, client.Version, client.Status, client.ConnTime)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetClients() ([]Client, error) {
|
||||
rows, err := DB.Query(`
|
||||
SELECT id, name, os, arch, version, status, conn_time, updated_at
|
||||
FROM clients ORDER BY id DESC LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []Client
|
||||
for rows.Next() {
|
||||
var c Client
|
||||
err := rows.Scan(&c.ID, &c.Name, &c.OS, &c.Arch, &c.Version, &c.Status, &c.ConnTime, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, c)
|
||||
}
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 用户 CRUD
|
||||
// ============================================================
|
||||
|
||||
func GetUserByUsername(username string) (*User, error) {
|
||||
var u User
|
||||
err := DB.QueryRow(`
|
||||
SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE username = ?
|
||||
`, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func CreateUser(username, passwordHash string) error {
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||
`, username, passwordHash)
|
||||
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)
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================
|
||||
# frps-console 一键部署脚本
|
||||
# 支持:Linux x86_64 / ARM64 / ARMv7
|
||||
# 自动安装:git / curl / wget / Go / Docker
|
||||
#
|
||||
# 用法:
|
||||
# ./deploy.sh # 完整交互流程
|
||||
# ./deploy.sh --yes # 跳过确认,直接执行
|
||||
# ./deploy.sh --check # 只检测环境,不执行
|
||||
# ./deploy.sh --dry-run # 显示将执行的操作,不实际执行
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m'
|
||||
|
||||
REPO_URL="https://git.whitetop.xyz/lxh2875931338/frps-console.git"
|
||||
BRANCH="main"
|
||||
WORK_DIR="/tmp/frps-console-build"
|
||||
DEFAULT_PORT=9365
|
||||
DEFAULT_DEPLOY_DIR="/opt/frps-console"
|
||||
IMAGE_NAME="frps-console"
|
||||
GO_VERSION="1.25.0"
|
||||
|
||||
# 状态变量
|
||||
OS=""
|
||||
OS_VERSION=""
|
||||
ARCH=""
|
||||
GO_ARCH=""
|
||||
HAS_GIT=false
|
||||
HAS_CURL=false
|
||||
HAS_WGET=false
|
||||
HAS_GO=false
|
||||
HAS_DOCKER=false
|
||||
NEED_INSTALL_GIT=false
|
||||
NEED_INSTALL_CURL=false
|
||||
NEED_INSTALL_WGET=false
|
||||
NEED_INSTALL_GO=false
|
||||
PORT=${DEFAULT_PORT}
|
||||
DEPLOY_DIR=${DEFAULT_DEPLOY_DIR}
|
||||
DATA_DIR="${DEPLOY_DIR}/data"
|
||||
CONTAINER_EXISTS=false
|
||||
CONTAINER_RUNNING=false
|
||||
SKIP_CONFIRM=false
|
||||
CHECK_ONLY=false
|
||||
DRY_RUN=false
|
||||
|
||||
# ---------- 打印函数 ----------
|
||||
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
print_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
print_step() { echo -e "\n${CYAN}▶${NC} $1"; }
|
||||
print_title() { echo -e "\n${MAGENTA}════════════════════════════════════════════════════════${NC}"; }
|
||||
print_subtitle() { echo -e "${MAGENTA} $1${NC}"; }
|
||||
|
||||
# ---------- 解析参数 ----------
|
||||
parse_args() {
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--yes|-y) SKIP_CONFIRM=true ;;
|
||||
--check) CHECK_ONLY=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--help|-h)
|
||||
echo "用法: ./deploy.sh [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --yes, -y 跳过所有确认提示,直接执行"
|
||||
echo " --check 只检测环境,不执行部署"
|
||||
echo " --dry-run 显示将执行的操作,不实际执行"
|
||||
echo " --help, -h 显示帮助信息"
|
||||
exit 0
|
||||
;;
|
||||
*) print_error "未知选项: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ---------- 检查 root ----------
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_error "请使用 root 权限运行此脚本"
|
||||
echo " sudo bash deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 检测环境 ----------
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
OS_VERSION=$VERSION_ID
|
||||
else
|
||||
print_error "无法识别操作系统"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
ARCH=$(uname -m)
|
||||
case $ARCH in
|
||||
x86_64|amd64) GO_ARCH="amd64" ;;
|
||||
aarch64|arm64) GO_ARCH="arm64" ;;
|
||||
armv7l|armhf) GO_ARCH="armv6l" ;;
|
||||
*) print_error "不支持的 CPU 架构: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_tools() {
|
||||
command -v git &> /dev/null && HAS_GIT=true || NEED_INSTALL_GIT=true
|
||||
command -v curl &> /dev/null && HAS_CURL=true || NEED_INSTALL_CURL=true
|
||||
command -v wget &> /dev/null && HAS_WGET=true || NEED_INSTALL_WGET=true
|
||||
command -v go &> /dev/null && HAS_GO=true || NEED_INSTALL_GO=true
|
||||
command -v docker &> /dev/null && HAS_DOCKER=true
|
||||
}
|
||||
|
||||
check_container() {
|
||||
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then
|
||||
CONTAINER_EXISTS=true
|
||||
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then
|
||||
CONTAINER_RUNNING=true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 打印环境摘要 ----------
|
||||
print_environment_summary() {
|
||||
print_title
|
||||
print_subtitle "环境检测结果"
|
||||
echo ""
|
||||
echo -e " ${CYAN}操作系统:${NC} $OS $OS_VERSION"
|
||||
echo -e " ${CYAN}CPU 架构:${NC} $ARCH → Go 架构: $GO_ARCH"
|
||||
echo ""
|
||||
echo " ${CYAN}必要工具:${NC}"
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
echo -e " git ✅ 已安装 ($(git --version | awk '{print $3}'))"
|
||||
else
|
||||
echo " git ❌ 未安装 (将自动安装)"
|
||||
fi
|
||||
if [ "$HAS_CURL" = true ]; then
|
||||
echo " curl ✅ 已安装"
|
||||
else
|
||||
echo " curl ❌ 未安装 (将自动安装)"
|
||||
fi
|
||||
if [ "$HAS_WGET" = true ]; then
|
||||
echo " wget ✅ 已安装"
|
||||
else
|
||||
echo " wget ❌ 未安装 (将自动安装)"
|
||||
fi
|
||||
echo ""
|
||||
echo " ${CYAN}Go 环境:${NC}"
|
||||
if [ "$HAS_GO" = true ]; then
|
||||
echo -e " go ✅ 已安装 ($(go version | awk '{print $3}'))"
|
||||
else
|
||||
echo " go ❌ 未安装 (将自动安装 Go ${GO_VERSION})"
|
||||
fi
|
||||
echo ""
|
||||
echo " ${CYAN}Docker 环境:${NC}"
|
||||
if [ "$HAS_DOCKER" = true ]; then
|
||||
echo -e " docker ✅ 已安装 ($(docker --version | awk '{print $3}' | tr -d ','))"
|
||||
else
|
||||
echo " docker ❌ 未安装"
|
||||
print_error "Docker 未安装,请先安装 Docker"
|
||||
echo ""
|
||||
echo " 快速安装:"
|
||||
echo " curl -fsSL https://get.docker.com | bash"
|
||||
echo " systemctl enable --now docker"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONTAINER_EXISTS" = true ]; then
|
||||
echo ""
|
||||
echo " ${CYAN}容器状态:${NC}"
|
||||
if [ "$CONTAINER_RUNNING" = true ]; then
|
||||
echo -e " frps-console ✅ 运行中"
|
||||
else
|
||||
echo -e " frps-console ⏸️ 已存在但未运行"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------- 生成部署计划 ----------
|
||||
generate_plan() {
|
||||
PLAN=""
|
||||
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
|
||||
local pkgs=""
|
||||
[ "$NEED_INSTALL_GIT" = true ] && pkgs="${pkgs} git"
|
||||
[ "$NEED_INSTALL_CURL" = true ] && pkgs="${pkgs} curl"
|
||||
[ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget"
|
||||
PLAN="${PLAN} • 安装必要工具:${pkgs}\n"
|
||||
fi
|
||||
if [ "$NEED_INSTALL_GO" = true ]; then
|
||||
PLAN="${PLAN} • 安装 Go ${GO_VERSION}\n"
|
||||
fi
|
||||
PLAN="${PLAN} • 拉取 frps-console 源码 (${BRANCH} 分支)\n"
|
||||
PLAN="${PLAN} • 编译 frps-console 二进制\n"
|
||||
PLAN="${PLAN} • 构建 Docker 镜像\n"
|
||||
if [ "$CONTAINER_EXISTS" = true ]; then
|
||||
PLAN="${PLAN} • 停止并删除旧容器\n"
|
||||
fi
|
||||
PLAN="${PLAN} • 启动 frps-console 容器 (端口 ${PORT})"
|
||||
}
|
||||
|
||||
print_deployment_plan() {
|
||||
print_title
|
||||
print_subtitle "部署计划"
|
||||
echo ""
|
||||
echo -e " ${CYAN}将执行以下操作:${NC}"
|
||||
echo -e "$(echo -e "$PLAN")"
|
||||
echo ""
|
||||
echo -e " ${CYAN}配置信息:${NC}"
|
||||
echo -e " ────────────────────────────────────"
|
||||
echo -e " 监听端口 : ${PORT}"
|
||||
echo -e " 部署目录 : ${DEPLOY_DIR}"
|
||||
echo -e " 数据目录 : ${DATA_DIR}"
|
||||
echo -e " ────────────────────────────────────"
|
||||
if [ "$CONTAINER_EXISTS" = true ]; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}⚠ 检测到已存在的 frps-console 容器${NC}"
|
||||
if [ "$CONTAINER_RUNNING" = true ]; then
|
||||
echo -e " 状态: 运行中 → 将被停止并重新创建"
|
||||
else
|
||||
echo -e " 状态: 已停止 → 将被删除并重新创建"
|
||||
fi
|
||||
echo -e " ${YELLOW}数据目录中的数据库文件将被保留${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------- 确认与自定义 ----------
|
||||
confirm_deploy() {
|
||||
if [ "$SKIP_CONFIRM" = true ]; then
|
||||
echo -e "${GREEN}▶ 已启用 --yes,自动确认${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -e -n "${CYAN}确认执行? 输入 Y 继续,输入 n 自定义配置 [Y/n]: ${NC}"
|
||||
read -r CONFIRM </dev/tty
|
||||
case $CONFIRM in
|
||||
n|N) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
custom_config() {
|
||||
print_title
|
||||
print_subtitle "自定义配置"
|
||||
echo ""
|
||||
read -p "请输入监听端口 [${DEFAULT_PORT}]: " INPUT_PORT </dev/tty
|
||||
PORT=${INPUT_PORT:-$DEFAULT_PORT}
|
||||
read -p "请输入部署目录 [${DEFAULT_DEPLOY_DIR}]: " INPUT_DEPLOY_DIR </dev/tty
|
||||
DEPLOY_DIR=${INPUT_DEPLOY_DIR:-$DEFAULT_DEPLOY_DIR}
|
||||
DATA_DIR="${DEPLOY_DIR}/data"
|
||||
echo ""
|
||||
echo -e " ${CYAN}更新后的配置:${NC}"
|
||||
echo -e " ────────────────────────────────────"
|
||||
echo -e " 监听端口 : ${PORT}"
|
||||
echo -e " 部署目录 : ${DEPLOY_DIR}"
|
||||
echo -e " 数据目录 : ${DATA_DIR}"
|
||||
echo -e " ────────────────────────────────────"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------- 执行部署 ----------
|
||||
do_deploy() {
|
||||
print_title
|
||||
print_subtitle "开始部署"
|
||||
echo ""
|
||||
|
||||
# 安装工具
|
||||
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
|
||||
print_step "安装必要工具..."
|
||||
local pkgs=""
|
||||
[ "$NEED_INSTALL_GIT" = true ] && pkgs="${pkgs} git"
|
||||
[ "$NEED_INSTALL_CURL" = true ] && pkgs="${pkgs} curl"
|
||||
[ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget"
|
||||
case $OS in
|
||||
opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll|opensuse-leap)
|
||||
zypper install -y $pkgs ;;
|
||||
ubuntu|debian|linuxmint) apt update -qq && apt install -y $pkgs ;;
|
||||
centos|rhel|fedora|rocky|almalinux) yum install -y $pkgs ;;
|
||||
alpine) apk add $pkgs ;;
|
||||
arch|manjaro|endeavouros) pacman -S --needed --noconfirm $pkgs ;;
|
||||
*) print_error "无法识别包管理器"; exit 1 ;;
|
||||
esac
|
||||
print_success "必要工具安装完成"
|
||||
fi
|
||||
|
||||
# 安装 Go
|
||||
if [ "$NEED_INSTALL_GO" = true ]; then
|
||||
print_step "安装 Go ${GO_VERSION} (${GO_ARCH})..."
|
||||
GO_TMP="/tmp/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||
if [ ! -f "$GO_TMP" ]; then
|
||||
MIRRORS=(
|
||||
"https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||
"https://golang.google.cn/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||
"https://dl.google.com/go/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||
)
|
||||
DOWNLOADED=false
|
||||
for MIRROR in "${MIRRORS[@]}"; do
|
||||
print_info "尝试: $MIRROR"
|
||||
if curl -# -f -L -o "$GO_TMP" "$MIRROR"; then
|
||||
print_success "下载成功"
|
||||
DOWNLOADED=true
|
||||
break
|
||||
else
|
||||
print_warn "失败,尝试下一个镜像..."
|
||||
rm -f "$GO_TMP"
|
||||
fi
|
||||
done
|
||||
if [ "$DOWNLOADED" = false ]; then
|
||||
print_error "所有镜像源均下载失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf "$GO_TMP"
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
/usr/local/go/bin/go env -w GOPROXY=https://goproxy.cn,direct
|
||||
/usr/local/go/bin/go env -w GOPRIVATE=git.whitetop.xyz
|
||||
print_success "Go ${GO_VERSION} 安装完成"
|
||||
fi
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# 拉取代码
|
||||
print_step "拉取代码..."
|
||||
rm -rf "$WORK_DIR"
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$WORK_DIR"
|
||||
cd "$WORK_DIR"
|
||||
mkdir -p bin static
|
||||
print_success "代码拉取完成"
|
||||
|
||||
# 修复 go.mod
|
||||
if grep -q "go 1.2[6-9]" go.mod 2>/dev/null; then
|
||||
print_warn "检测到 go.mod 版本过高,自动降级到 go 1.21"
|
||||
sed -i 's/go 1.2[6-9].*/go 1.21/' go.mod
|
||||
fi
|
||||
|
||||
# 下载依赖
|
||||
print_step "下载 Go 依赖..."
|
||||
go mod download
|
||||
print_success "依赖下载完成"
|
||||
|
||||
# 编译
|
||||
print_step "编译 frps-console..."
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o frps-console .
|
||||
if [ -f "frps-console" ]; then
|
||||
SIZE=$(du -h frps-console | cut -f1)
|
||||
print_success "编译完成 ($SIZE)"
|
||||
else
|
||||
print_error "编译失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 准备目录
|
||||
print_step "准备部署目录..."
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
if [ ! -f "$DATA_DIR/frps-console.db" ]; then
|
||||
touch "$DATA_DIR/frps-console.db"
|
||||
print_info "新数据目录已创建"
|
||||
else
|
||||
print_info "已有数据目录,保留现有数据"
|
||||
fi
|
||||
cp frps-console "$DEPLOY_DIR/"
|
||||
print_success "二进制已复制到 $DEPLOY_DIR"
|
||||
|
||||
# 构建镜像
|
||||
print_step "构建 Docker 镜像..."
|
||||
docker build -t "${IMAGE_NAME}:latest" .
|
||||
print_success "Docker 镜像构建完成"
|
||||
|
||||
# 处理旧容器
|
||||
if [ "$CONTAINER_EXISTS" = true ]; then
|
||||
print_step "处理旧容器..."
|
||||
if [ "$CONTAINER_RUNNING" = true ]; then
|
||||
docker stop frps-console 2>/dev/null || true
|
||||
fi
|
||||
docker rm frps-console 2>/dev/null || true
|
||||
print_success "旧容器已清理"
|
||||
fi
|
||||
|
||||
# 启动容器
|
||||
print_step "启动 frps-console 容器..."
|
||||
docker run -d \
|
||||
--name frps-console \
|
||||
--restart=always \
|
||||
--network host \
|
||||
-v ${DATA_DIR}:/app/data \
|
||||
-e PORT=${PORT} \
|
||||
-e TZ=Asia/Shanghai \
|
||||
${IMAGE_NAME}:latest
|
||||
|
||||
if docker ps | grep -q frps-console; then
|
||||
print_success "容器启动成功!"
|
||||
else
|
||||
print_error "容器启动失败,请检查日志: docker logs frps-console"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 frps 子进程
|
||||
print_step "检查 frps 状态..."
|
||||
sleep 3
|
||||
if docker exec frps-console ps aux 2>/dev/null | grep -q "[f]rps -c"; then
|
||||
print_success "frps 进程运行正常"
|
||||
else
|
||||
print_warn "frps 进程未运行(可能配置为空,请在 WebUI 中导入 TOML)"
|
||||
fi
|
||||
|
||||
# 清理
|
||||
print_step "清理临时文件..."
|
||||
rm -rf "$WORK_DIR"
|
||||
print_success "临时文件已清理"
|
||||
|
||||
print_title
|
||||
print_success "frps-console 部署完成!"
|
||||
print_title
|
||||
echo ""
|
||||
echo -e " ${CYAN}📍 访问地址:${NC} http://$(hostname -I | awk '{print $1}'):${PORT}"
|
||||
echo -e " ${CYAN}📂 数据目录:${NC} ${DATA_DIR}"
|
||||
echo -e " ${CYAN}🐳 容器名称:${NC} frps-console"
|
||||
echo ""
|
||||
echo -e " ${CYAN}常用命令:${NC}"
|
||||
echo " docker logs frps-console # 查看日志"
|
||||
echo " docker restart frps-console # 重启服务"
|
||||
echo " docker stop frps-console # 停止服务"
|
||||
echo " docker start frps-console # 启动服务"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}首次访问需要注册管理员账户${NC}"
|
||||
echo ""
|
||||
print_title
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
main() {
|
||||
clear 2>/dev/null || true
|
||||
parse_args "$@"
|
||||
check_root
|
||||
|
||||
print_step "检测环境..."
|
||||
detect_os
|
||||
detect_arch
|
||||
check_tools
|
||||
check_container
|
||||
|
||||
print_environment_summary
|
||||
|
||||
if [ "$CHECK_ONLY" = true ]; then
|
||||
print_info "环境检测完成(--check 模式)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$HAS_DOCKER" = false ]; then
|
||||
print_error "Docker 未安装,请先安装 Docker"
|
||||
echo ""
|
||||
echo " 快速安装:"
|
||||
echo " curl -fsSL https://get.docker.com | bash"
|
||||
echo " systemctl enable --now docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_plan
|
||||
print_deployment_plan
|
||||
|
||||
if ! confirm_deploy; then
|
||||
custom_config
|
||||
print_deployment_plan
|
||||
if ! confirm_deploy; then
|
||||
print_info "已取消部署"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
print_info "演练模式(--dry-run),不实际执行部署"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
do_deploy
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build linux || darwin || freebsd || netbsd || openbsd || solaris
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr 为 Unix 系统设置 Setsid,让 frpc 进程脱离父进程独立运行
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setsid = true
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed bin/*
|
||||
var embeddedFrps embed.FS
|
||||
|
||||
//go:embed frps.tmpl
|
||||
var FrpsTemplateContent string
|
||||
|
||||
var (
|
||||
cachedFrpsPath string
|
||||
frpsPathMutex sync.Mutex
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 获取 frps 路径
|
||||
// ============================================================
|
||||
|
||||
func getFrpsPath() (string, error) {
|
||||
frpsPathMutex.Lock()
|
||||
defer frpsPathMutex.Unlock()
|
||||
|
||||
if cachedFrpsPath != "" {
|
||||
if _, err := os.Stat(cachedFrpsPath); err == nil {
|
||||
return cachedFrpsPath, nil
|
||||
}
|
||||
cachedFrpsPath = ""
|
||||
}
|
||||
|
||||
var fileName string
|
||||
switch {
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
|
||||
fileName = "frps_windows_amd64.exe"
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
fileName = "frps_linux_amd64"
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "arm64":
|
||||
fileName = "frps_linux_arm64"
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "arm":
|
||||
fileName = "frps_linux_arm_hf"
|
||||
default:
|
||||
path, err := exec.LookPath("frps")
|
||||
if err == nil {
|
||||
cachedFrpsPath = path
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
localPath := filepath.Join(".", "bin", fileName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
cachedFrpsPath = localPath
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
data, err := embeddedFrps.ReadFile("bin/" + fileName)
|
||||
if err == nil {
|
||||
tmpPath := filepath.Join(os.TempDir(), "frps")
|
||||
if runtime.GOOS == "windows" {
|
||||
tmpPath += ".exe"
|
||||
}
|
||||
if err := os.WriteFile(tmpPath, data, 0755); err == nil {
|
||||
cachedFrpsPath = tmpPath
|
||||
return tmpPath, nil
|
||||
}
|
||||
if _, statErr := os.Stat(tmpPath); statErr == nil {
|
||||
cachedFrpsPath = tmpPath
|
||||
return tmpPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath("frps")
|
||||
if err == nil {
|
||||
cachedFrpsPath = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到 frps 文件")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生成 frps.toml
|
||||
// ============================================================
|
||||
|
||||
func GenerateFrpsConfig() error {
|
||||
cfg, err := GetGlobalConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取全局配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 直接传递 cfg 即可,模板里能读到 .WireProtocolV2
|
||||
var tmplContent string
|
||||
if _, err := os.Stat("frps.tmpl"); err == nil {
|
||||
content, readErr := os.ReadFile("frps.tmpl")
|
||||
if readErr == nil {
|
||||
tmplContent = string(content)
|
||||
} else {
|
||||
tmplContent = FrpsTemplateContent
|
||||
}
|
||||
} else {
|
||||
tmplContent = FrpsTemplateContent
|
||||
}
|
||||
|
||||
tmpl, err := template.New("frps").Parse(tmplContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析模板失败: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, cfg); err != nil {
|
||||
return fmt.Errorf("渲染模板失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("./frps.toml", buf.Bytes(), 0644); err != nil {
|
||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// frps 进程管理
|
||||
// ============================================================
|
||||
|
||||
func isFrpsRunning() bool {
|
||||
pidData, err := os.ReadFile("./frps.pid")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(pidData)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func StartFrps() error {
|
||||
frpsPath, err := getFrpsPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 frps 路径失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("./frps.toml"); os.IsNotExist(err) {
|
||||
if err := GenerateFrpsConfig(); err != nil {
|
||||
return fmt.Errorf("生成配置文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if isFrpsRunning() {
|
||||
return nil
|
||||
}
|
||||
|
||||
os.Remove("./frps.pid")
|
||||
|
||||
cmd := exec.Command(frpsPath, "-c", "./frps.toml")
|
||||
setWindowHide(cmd)
|
||||
setSysProcAttr(cmd)
|
||||
|
||||
logFile, err := os.OpenFile("./frps.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开日志文件失败: %w", err)
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 frps 失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile("./frps.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil {
|
||||
return fmt.Errorf("保存 PID 失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func StopFrps() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command("taskkill", "/F", "/IM", "frps.exe")
|
||||
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") {
|
||||
return fmt.Errorf("停止 frps 失败: %w", err)
|
||||
}
|
||||
os.Remove("./frps.pid")
|
||||
return nil
|
||||
}
|
||||
|
||||
pidData, err := os.ReadFile("./frps.pid")
|
||||
if err != nil {
|
||||
cmd := exec.Command("pkill", "-f", "frps")
|
||||
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") {
|
||||
return fmt.Errorf("停止 frps 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData)))
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
os.Remove("./frps.pid")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := process.Kill(); err != nil {
|
||||
return fmt.Errorf("杀死进程失败: %w", err)
|
||||
}
|
||||
|
||||
os.Remove("./frps.pid")
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetFrpsStatus() (bool, error) {
|
||||
return isFrpsRunning(), nil
|
||||
}
|
||||
|
||||
func ReloadFrps() error {
|
||||
running := isFrpsRunning()
|
||||
if !running {
|
||||
return StartFrps()
|
||||
}
|
||||
|
||||
frpsPath, err := getFrpsPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 frps 路径失败: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(frpsPath, "reload", "-c", "./frps.toml")
|
||||
_, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 热加载失败 (%v),自动降级为重启 frps", err)
|
||||
if stopErr := StopFrps(); stopErr != nil {
|
||||
return fmt.Errorf("停止 frps 失败: %w", stopErr)
|
||||
}
|
||||
if startErr := StartFrps(); startErr != nil {
|
||||
return fmt.Errorf("启动 frps 失败: %w", startErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
bindPort = {{.BindPort}}
|
||||
|
||||
auth.token = "{{.Token}}"
|
||||
|
||||
log.to = "./frps.log"
|
||||
log.level = "{{.LogLevel}}"
|
||||
log.maxDays = {{.LogMaxDays}}
|
||||
|
||||
[transport]
|
||||
tcpMux = {{.TcpMux}}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func setWindowHide(cmd *exec.Cmd) {
|
||||
// 非 Windows 平台什么都不做
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setWindowHide Windows 隐藏窗口
|
||||
func setWindowHide(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.HideWindow = true
|
||||
}
|
||||
|
||||
// setSysProcAttr Windows 不需要 Setsid,空操作
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
// Windows 不需要 Setsid,什么都不做
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
module frps-console
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
golang.org/x/crypto v0.54.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := InitDB(); err != nil {
|
||||
log.Fatal("❌ 数据库初始化失败:", err)
|
||||
}
|
||||
|
||||
if err := GenerateFrpsConfig(); err != nil {
|
||||
log.Println("⚠️ 生成配置文件失败:", err)
|
||||
}
|
||||
|
||||
if err := StartFrps(); err != nil {
|
||||
log.Println("⚠️ 启动 frps 失败:", err)
|
||||
}
|
||||
|
||||
go startWatchdog()
|
||||
|
||||
r := SetupRouter()
|
||||
log.Println("🚀 frps-console 启动成功!")
|
||||
log.Println("📍 访问地址: http://localhost:9365")
|
||||
log.Println("📍 API 地址: http://localhost:9365/api")
|
||||
|
||||
if err := r.Run(":9365"); err != nil {
|
||||
log.Fatal("❌ 服务启动失败:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startWatchdog() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if !isFrpsRunning() {
|
||||
log.Println("⚠️ frps 进程已停止,自动重启...")
|
||||
if err := StartFrps(); err != nil {
|
||||
log.Printf("❌ 自动重启 frps 失败: %v", err)
|
||||
} else {
|
||||
log.Println("✅ frps 已自动重启")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Platform struct {
|
||||
OS string
|
||||
Arch string
|
||||
Name string
|
||||
Ext string
|
||||
}
|
||||
|
||||
var platforms = []Platform{
|
||||
{"windows", "amd64", "Windows x86-64", ".exe"},
|
||||
{"linux", "amd64", "Linux x86-64", ""},
|
||||
{"linux", "arm64", "Linux ARM64", ""},
|
||||
{"linux", "arm", "Linux ARMv7l", ""},
|
||||
}
|
||||
|
||||
const (
|
||||
Version = "2.0.0"
|
||||
BuildTime = "2026-07-26"
|
||||
Binary = "frps-console"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
target := os.Args[1]
|
||||
switch target {
|
||||
case "all":
|
||||
buildAll()
|
||||
return
|
||||
case "clean":
|
||||
clean()
|
||||
return
|
||||
case "list":
|
||||
listPlatforms()
|
||||
return
|
||||
}
|
||||
for _, p := range platforms {
|
||||
if target == p.OS+"/"+p.Arch {
|
||||
build(p, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Println("❌ 不支持的平台:", target)
|
||||
fmt.Println(" 可用: windows/amd64, linux/amd64, linux/arm64, linux/arm")
|
||||
fmt.Println(" 或: all, list, clean")
|
||||
return
|
||||
}
|
||||
interactiveMenu()
|
||||
}
|
||||
|
||||
func listPlatforms() {
|
||||
fmt.Println("可用平台:")
|
||||
for i, p := range platforms {
|
||||
fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func interactiveMenu() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" frps-console 多平台构建工具 v" + Version)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
for i, p := range platforms {
|
||||
fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch)
|
||||
}
|
||||
fmt.Println(" a. 全部构建")
|
||||
fmt.Println(" c. 清理构建产物")
|
||||
fmt.Println(" q. 退出")
|
||||
fmt.Println()
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("请选择: ")
|
||||
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(strings.ToLower(input))
|
||||
|
||||
switch input {
|
||||
case "q":
|
||||
fmt.Println("已退出")
|
||||
return
|
||||
case "c":
|
||||
clean()
|
||||
return
|
||||
case "a":
|
||||
buildAll()
|
||||
return
|
||||
default:
|
||||
var idx int
|
||||
if n, err := fmt.Sscanf(input, "%d", &idx); n == 1 && err == nil && idx >= 1 && idx <= len(platforms) {
|
||||
build(platforms[idx-1], false)
|
||||
return
|
||||
}
|
||||
fmt.Println("❌ 无效选择")
|
||||
interactiveMenu()
|
||||
}
|
||||
}
|
||||
|
||||
func buildAll() {
|
||||
for _, p := range platforms {
|
||||
build(p, true)
|
||||
}
|
||||
fmt.Println("\n✅ 全部构建完成!产物位于 build/ 目录")
|
||||
}
|
||||
|
||||
func build(p Platform, silent bool) {
|
||||
if !silent {
|
||||
fmt.Printf("🔨 构建 %s (%s/%s)...\n", p.Name, p.OS, p.Arch)
|
||||
}
|
||||
|
||||
outDir := "./build"
|
||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||
fmt.Printf("❌ 创建目录失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
outName := Binary + "-" + p.OS + "-" + p.Arch
|
||||
if p.Arch == "arm" {
|
||||
outName += "v7"
|
||||
}
|
||||
outName += p.Ext
|
||||
outPath := filepath.Join(outDir, outName)
|
||||
|
||||
cmd := exec.Command("go", "build",
|
||||
"-ldflags=-s -w -X main.version="+Version,
|
||||
"-o", outPath,
|
||||
".",
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS="+p.OS,
|
||||
"GOARCH="+p.Arch,
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
if p.Arch == "arm" {
|
||||
cmd.Env = append(cmd.Env, "GOARM=7")
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf("❌ 构建失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !silent {
|
||||
fmt.Printf("✅ 构建完成: %s\n", outPath)
|
||||
if info, err := os.Stat(outPath); err == nil {
|
||||
size := float64(info.Size()) / 1024 / 1024
|
||||
fmt.Printf(" 📦 %.2f MB\n", size)
|
||||
}
|
||||
}
|
||||
|
||||
if !silent {
|
||||
packageBinary(outPath, p)
|
||||
}
|
||||
}
|
||||
|
||||
func packageBinary(binPath string, p Platform) {
|
||||
base := strings.TrimSuffix(filepath.Base(binPath), p.Ext)
|
||||
dir := filepath.Dir(binPath)
|
||||
|
||||
if p.Ext == ".exe" {
|
||||
zipName := filepath.Join(dir, base+".zip")
|
||||
cmd := exec.Command("zip", "-j", zipName, binPath)
|
||||
if err := cmd.Run(); err == nil {
|
||||
fmt.Printf(" 📦 已打包: %s\n", zipName)
|
||||
}
|
||||
} else {
|
||||
tarName := filepath.Join(dir, base+".tar.gz")
|
||||
cmd := exec.Command("tar", "-czf", tarName, "-C", dir, filepath.Base(binPath))
|
||||
if err := cmd.Run(); err == nil {
|
||||
fmt.Printf(" 📦 已打包: %s\n", tarName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clean() {
|
||||
fmt.Println("🧹 清理构建产物...")
|
||||
if err := os.RemoveAll("./build"); err != nil {
|
||||
fmt.Printf("❌ 清理失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("✅ 清理完成")
|
||||
}
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
// app.js - 普通脚本(非模块)
|
||||
|
||||
const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = Vue;
|
||||
|
||||
// ============================================================
|
||||
// 1. 基础 API
|
||||
// ============================================================
|
||||
const API_BASE = "/api";
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem("frps_token") || "";
|
||||
}
|
||||
|
||||
function getHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + getToken(),
|
||||
};
|
||||
}
|
||||
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const url = API_BASE + endpoint;
|
||||
const headers =
|
||||
options.body instanceof FormData
|
||||
? { Authorization: "Bearer " + getToken() }
|
||||
: getHeaders();
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...headers, ...(options.headers || {}) },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Auth
|
||||
// ============================================================
|
||||
function parseJwtExpire(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000;
|
||||
} catch {
|
||||
return Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuthState(token, username) {
|
||||
const expire = parseJwtExpire(token);
|
||||
localStorage.setItem("frps_token", token);
|
||||
localStorage.setItem("frps_token_expire", String(expire));
|
||||
localStorage.setItem("frps_username", username);
|
||||
return expire;
|
||||
}
|
||||
|
||||
function loadAuthState() {
|
||||
const token = localStorage.getItem("frps_token");
|
||||
const expire = parseInt(localStorage.getItem("frps_token_expire") || "0", 10);
|
||||
const username = localStorage.getItem("frps_username") || "";
|
||||
if (token && expire > Date.now()) {
|
||||
return { token, expire, username, valid: true };
|
||||
}
|
||||
clearAuthState();
|
||||
return { token: "", expire: 0, username: "", valid: false };
|
||||
}
|
||||
|
||||
function clearAuthState() {
|
||||
localStorage.removeItem("frps_token");
|
||||
localStorage.removeItem("frps_token_expire");
|
||||
localStorage.removeItem("frps_username");
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const data = await apiFetch("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (data.code === 0) {
|
||||
const token = data.data.token;
|
||||
const expire = saveAuthState(token, username);
|
||||
return { success: true, token, expire };
|
||||
}
|
||||
return { success: false, error: data.msg || "登录失败" };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Config
|
||||
// ============================================================
|
||||
const defaultConfig = {
|
||||
bindPort: 9358,
|
||||
token: "CHANGE_ME",
|
||||
logLevel: "info",
|
||||
logMaxDays: 3,
|
||||
tcpMux: true,
|
||||
};
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const data = await apiFetch("/config");
|
||||
if (data.code === 0) {
|
||||
return { ...defaultConfig, ...data.data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("加载配置失败", e);
|
||||
}
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
|
||||
async function saveConfigApi(config) {
|
||||
const data = await apiFetch("/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Clients
|
||||
// ============================================================
|
||||
async function loadClients() {
|
||||
try {
|
||||
const data = await apiFetch("/clients");
|
||||
if (data.code === 0) {
|
||||
return data.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("加载客户端失败", e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function filterClients(list, keyword) {
|
||||
if (!keyword) return list;
|
||||
const kw = keyword.toLowerCase();
|
||||
return list.filter(
|
||||
(c) =>
|
||||
(c.name || "").toLowerCase().includes(kw) ||
|
||||
String(c.id).includes(kw) ||
|
||||
(c.os || "").toLowerCase().includes(kw)
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. Frps
|
||||
// ============================================================
|
||||
async function getFrpsStatus() {
|
||||
try {
|
||||
const data = await apiFetch("/frps/status");
|
||||
if (data.code === 0) {
|
||||
return data.data.running || false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("获取 frps 状态失败", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. Logs
|
||||
// ============================================================
|
||||
async function fetchLogsApi() {
|
||||
try {
|
||||
const data = await apiFetch("/frps/log");
|
||||
if (data.code === 0) {
|
||||
return { lines: data.data.lines || [], total: data.data.total || 0, error: data.data.error || "" };
|
||||
}
|
||||
return { lines: [], total: 0, error: "加载失败" };
|
||||
} catch (e) {
|
||||
return { lines: [], total: 0, error: "网络错误" };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7. UI State
|
||||
// ============================================================
|
||||
const activeTab = ref("clients");
|
||||
const searchKeyword = ref("");
|
||||
const showToken = ref(false);
|
||||
|
||||
// ============================================================
|
||||
// 8. Vue App
|
||||
// ============================================================
|
||||
const app = createApp({
|
||||
setup() {
|
||||
// ---- 登录态 ----
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const loginError = ref("");
|
||||
const loginForm = reactive({ username: "", password: "" });
|
||||
const token = ref("");
|
||||
const tokenExpireTime = ref(0);
|
||||
const transitioning = ref(false);
|
||||
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 clients = ref([]);
|
||||
const clientsLoaded = ref(false);
|
||||
const frpsRunning = ref(false);
|
||||
|
||||
// ---- 修改密码 ----
|
||||
const passwordChange = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const passwordChangeError = ref("");
|
||||
const passwordChangeSuccess = ref("");
|
||||
|
||||
// ---- 日志 ----
|
||||
const logLines = ref([]);
|
||||
const logTotal = ref(0);
|
||||
const logError = ref("");
|
||||
const logLastUpdate = ref("");
|
||||
const logAutoScroll = ref(true);
|
||||
let logTimer = null;
|
||||
let logFetching = false;
|
||||
|
||||
// ---- 初始化 ----
|
||||
onMounted(() => {
|
||||
const saved = loadAuthState();
|
||||
if (saved.valid) {
|
||||
token.value = saved.token;
|
||||
tokenExpireTime.value = saved.expire;
|
||||
loginForm.username = saved.username;
|
||||
loggedIn.value = true;
|
||||
loadAllData().then(() => {
|
||||
contentVisible.value = true;
|
||||
startExpireTimer();
|
||||
});
|
||||
} else {
|
||||
checkUsers();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Tab 切换监听日志轮询 ----
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === "logs") {
|
||||
startLogPolling();
|
||||
} else {
|
||||
stopLogPolling();
|
||||
}
|
||||
if (newTab === "clients") {
|
||||
fetchClients();
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
doLogout();
|
||||
alert("登录已超时,请重新登录");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
const [cfg, running] = await Promise.all([
|
||||
loadConfig(),
|
||||
getFrpsStatus(),
|
||||
]);
|
||||
Object.assign(globalConfig, cfg);
|
||||
frpsRunning.value = running;
|
||||
await fetchClients();
|
||||
}
|
||||
|
||||
async function fetchClients() {
|
||||
clientsLoaded.value = false;
|
||||
const list = await loadClients();
|
||||
clients.value = list;
|
||||
clientsLoaded.value = true;
|
||||
}
|
||||
|
||||
// ---- 日志轮询 ----
|
||||
async function fetchLogs() {
|
||||
if (logFetching) return;
|
||||
logFetching = true;
|
||||
try {
|
||||
const result = await fetchLogsApi();
|
||||
logLines.value = result.lines;
|
||||
logTotal.value = result.total;
|
||||
logError.value = result.error;
|
||||
logLastUpdate.value = new Date().toLocaleTimeString();
|
||||
if (logAutoScroll.value) {
|
||||
await nextTick();
|
||||
const container = document.getElementById("logContainer");
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logError.value = "网络错误";
|
||||
} finally {
|
||||
logFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startLogPolling() {
|
||||
if (logTimer) return;
|
||||
fetchLogs();
|
||||
logTimer = setInterval(fetchLogs, 8000);
|
||||
}
|
||||
|
||||
function stopLogPolling() {
|
||||
if (logTimer) {
|
||||
clearInterval(logTimer);
|
||||
logTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
fetchLogs();
|
||||
}
|
||||
|
||||
function scrollLogToBottom() {
|
||||
const container = document.getElementById("logContainer");
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 登录 ----
|
||||
const doLogin = async () => {
|
||||
if (!loginForm.username || !loginForm.password) {
|
||||
loginError.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
loginError.value = "";
|
||||
const result = await login(loginForm.username, loginForm.password);
|
||||
if (result.success) {
|
||||
token.value = result.token;
|
||||
tokenExpireTime.value = result.expire;
|
||||
transitioning.value = true;
|
||||
loggedIn.value = true;
|
||||
setTimeout(async () => {
|
||||
transitioning.value = false;
|
||||
await loadAllData();
|
||||
loading.value = false;
|
||||
setTimeout(() => {
|
||||
contentVisible.value = true;
|
||||
startExpireTimer();
|
||||
}, 300);
|
||||
}, 500);
|
||||
} else {
|
||||
loginError.value = result.error;
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 注册 ----
|
||||
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) {
|
||||
isFirstRun.value = false;
|
||||
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);
|
||||
stopLogPolling();
|
||||
contentVisible.value = false;
|
||||
loggedIn.value = false;
|
||||
token.value = "";
|
||||
tokenExpireTime.value = 0;
|
||||
loginForm.username = "";
|
||||
loginForm.password = "";
|
||||
loading.value = false;
|
||||
transitioning.value = false;
|
||||
clearAuthState();
|
||||
isFirstRun.value = false;
|
||||
registerForm.username = "";
|
||||
registerForm.password = "";
|
||||
registerForm.confirmPassword = "";
|
||||
registerError.value = "";
|
||||
registering.value = false;
|
||||
};
|
||||
|
||||
// ---- 修改密码 ----
|
||||
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) {
|
||||
frpsRunning.value = await getFrpsStatus();
|
||||
alert("配置已更新并热加载");
|
||||
} else {
|
||||
alert("保存失败: " + result.msg);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 导入导出 ----
|
||||
const triggerImport = () => {
|
||||
document.getElementById("tomlFileInput").click();
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/import/toml", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer " + getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
alert(data.msg);
|
||||
await loadAllData();
|
||||
} else {
|
||||
alert("导入失败: " + data.msg);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("网络错误: " + err.message);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const exportToml = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/export/toml", {
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer " + getToken() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert("导出失败: " + (data.msg || "未知错误"));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "frps.toml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
alert("导出失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 计算属性 ----
|
||||
const filteredClients = computed(() =>
|
||||
filterClients(clients.value, searchKeyword.value)
|
||||
);
|
||||
|
||||
const showDefaultTip = computed(() => {
|
||||
const token = globalConfig.token;
|
||||
return !token || token === "CHANGE_ME" || token.trim() === "";
|
||||
});
|
||||
|
||||
// ---- 返回 ----
|
||||
return {
|
||||
loggedIn,
|
||||
loading,
|
||||
loginError,
|
||||
loginForm,
|
||||
doLogin,
|
||||
doLogout,
|
||||
|
||||
isFirstRun,
|
||||
registerForm,
|
||||
registerError,
|
||||
registering,
|
||||
doRegister,
|
||||
|
||||
activeTab,
|
||||
globalConfig,
|
||||
saveConfig,
|
||||
|
||||
clients,
|
||||
clientsLoaded,
|
||||
filteredClients,
|
||||
fetchClients,
|
||||
frpsRunning,
|
||||
|
||||
searchKeyword,
|
||||
transitioning,
|
||||
contentVisible,
|
||||
|
||||
triggerImport,
|
||||
handleImport,
|
||||
exportToml,
|
||||
|
||||
passwordChange,
|
||||
passwordChangeError,
|
||||
passwordChangeSuccess,
|
||||
changePassword,
|
||||
|
||||
logLines,
|
||||
logTotal,
|
||||
logError,
|
||||
logLastUpdate,
|
||||
logAutoScroll,
|
||||
refreshLogs,
|
||||
scrollLogToBottom,
|
||||
|
||||
showToken,
|
||||
showDefaultTip,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,324 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frps-console</title>
|
||||
|
||||
<!-- Vue 3 CDN -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.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" />
|
||||
<link rel="stylesheet" href="/static/style-4.css" />
|
||||
</head>
|
||||
|
||||
<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">
|
||||
<img src="/static/logo.svg" alt="frps-console" class="logo-img" />
|
||||
<span class="logo-text">frps-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">
|
||||
<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 v-else class="main-panel" :class="{ visible: contentVisible }">
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="top-bar">
|
||||
<div class="top-left">
|
||||
<img src="/static/logo.svg" alt="frps-console" class="nav-logo" />
|
||||
<div class="brand-info">
|
||||
<span class="logo-text">frps-console</span>
|
||||
<span class="status-wrapper">
|
||||
<span class="status-dot" :class="{ active: frpsRunning }"></span>
|
||||
<span class="status-text">{{ frpsRunning ? 'frps 运行中' : 'frps 已停止' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<span class="user-name">{{ loginForm.username }}</span>
|
||||
<button class="logout-btn" @click="doLogout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 栏 -->
|
||||
<div class="tab-bar">
|
||||
<span class="tab-item" :class="{ active: activeTab === 'clients' }"
|
||||
@click="activeTab = 'clients'">客户端列表</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'config' }"
|
||||
@click="activeTab = 'config'">全局配置信息</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'logs' }"
|
||||
@click="activeTab = 'logs'">运行日志</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="content-area">
|
||||
|
||||
<!-- ====== 客户端列表 ====== -->
|
||||
<div v-if="activeTab === 'clients'" 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>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<input class="search-input" placeholder="搜索客户端..." v-model="searchKeyword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proxy-list">
|
||||
<div v-for="c in filteredClients" :key="c.id" class="proxy-card">
|
||||
<div class="proxy-row">
|
||||
<div class="proxy-left">
|
||||
<span class="status-dot" :class="{ active: c.status === 'online' }"></span>
|
||||
<span class="proxy-name">{{ c.name || c.id }}</span>
|
||||
<span class="proxy-tag">{{ c.os }} / {{ c.arch }}</span>
|
||||
<span class="proxy-tag">v{{ c.version }}</span>
|
||||
</div>
|
||||
<div class="proxy-right">
|
||||
<span class="remote-port">{{ c.status === 'online' ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-footer">
|
||||
<span class="status-label">连接时间: {{ c.connTime || '--' }}</span>
|
||||
<span class="status-label">ID: {{ c.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredClients.length === 0" class="empty-state">
|
||||
{{ clientsLoaded ? '暂无客户端连接' : '加载中...' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 全局配置信息 ====== -->
|
||||
<div v-if="activeTab === 'config'" class="tab-content config-tab-content">
|
||||
<!-- 页面标题 -->
|
||||
<div class="config-page-header">
|
||||
<h2>全局配置信息</h2>
|
||||
<p class="config-subtitle">管理 frps 服务端参数与传输设置</p>
|
||||
</div>
|
||||
|
||||
<!-- 首次使用提示 -->
|
||||
<div v-if="showDefaultTip" class="config-tip">
|
||||
首次使用请修改「认证令牌」为您的真实 frps 配置
|
||||
</div>
|
||||
|
||||
<!-- ===== 账户管理 ===== -->
|
||||
<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>
|
||||
|
||||
<!-- ===== 卡片1: 服务器连接 ===== -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<span class="card-title">服务器连接</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-row">
|
||||
<label>绑定端口</label>
|
||||
<input type="number" v-model="globalConfig.bindPort" placeholder="9358" />
|
||||
</div>
|
||||
<div class="form-row" style="grid-column: 1 / -1;">
|
||||
<label>认证令牌</label>
|
||||
<div class="token-input-wrapper">
|
||||
<input :type="showToken ? 'text' : 'password'" v-model="globalConfig.token"
|
||||
placeholder="CHANGE_ME" />
|
||||
<button class="token-toggle-btn" @click="showToken = !showToken">
|
||||
{{ showToken ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 卡片2: 传输配置 ===== -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<span class="card-title">传输配置</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<!-- TCP 多路复用(只读,占满一行) -->
|
||||
<div class="form-row" style="grid-column: 1 / -1;">
|
||||
<label>TCP 多路复用</label>
|
||||
<div class="readonly-value">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text active">已强制开启</span>
|
||||
<span class="hint-text">优化连接性能,减少延迟,frp 官方推荐开启</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v2 协议状态(只读展示,无实际配置) -->
|
||||
<div class="form-row v2-switch-row">
|
||||
<label>frp v2 协议支持</label>
|
||||
<div class="v2-switch-wrapper">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text active">已就绪</span>
|
||||
<span class="v2-badge v2-enabled">frps 已支持 v2 协议,请在 frpc 侧开启</span>
|
||||
</div>
|
||||
<span class="hint-text v2-hint">
|
||||
frps 已内置 v2 协议支持,无需额外配置。如需启用,请在 frpc 配置中设置 transport.wireProtocol = "v2"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 卡片3: 日志配置 ===== -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<span class="card-title">日志配置</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-row">
|
||||
<label>日志级别</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="error">error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>日志保留天数(天)</label>
|
||||
<input type="number" v-model="globalConfig.logMaxDays" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<button class="save-config-btn" @click="saveConfig">保存配置并热加载</button>
|
||||
</div>
|
||||
|
||||
<!-- ====== 运行日志 ====== -->
|
||||
<div v-else-if="activeTab === 'logs'" class="tab-content log-tab-content">
|
||||
<!-- 工具栏 -->
|
||||
<div class="log-toolbar">
|
||||
<div class="log-toolbar-left">
|
||||
<span class="log-info-badge">共 {{ logTotal }} 行</span>
|
||||
<span v-if="logError" class="log-error-badge">{{ logError }}</span>
|
||||
</div>
|
||||
<div class="log-toolbar-right">
|
||||
<label class="log-auto-scroll-label">
|
||||
<input type="checkbox" v-model="logAutoScroll" />
|
||||
自动滚动
|
||||
</label>
|
||||
<button class="log-btn" @click="refreshLogs">刷新</button>
|
||||
<button class="log-btn" @click="scrollLogToBottom">↓ 滚动到底部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志显示区域 -->
|
||||
<div class="log-terminal" id="logContainer">
|
||||
<div v-if="logLines.length === 0 && !logError" class="log-empty">
|
||||
暂无日志,frps 尚未产生输出
|
||||
</div>
|
||||
<div v-else class="log-lines">
|
||||
<div v-for="(line, idx) in logLines" :key="idx" class="log-line">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态 -->
|
||||
<div class="log-footer">
|
||||
<span>上次更新: {{ logLastUpdate || '--:--:--' }}</span>
|
||||
<span class="log-polling-status">自动刷新中 (8s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏文件选择器 -->
|
||||
<input type="file" id="tomlFileInput" accept=".toml" style="display:none" @change="handleImport" />
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,184 @@
|
||||
/* ===== style-1.css - 全局基础 ===== */
|
||||
|
||||
/* ---------- 字体定义 ---------- */
|
||||
@font-face {
|
||||
font-family: 'HarmonyOS Sans SC';
|
||||
src: local('HarmonyOS Sans SC'),
|
||||
local('PingFang SC'),
|
||||
local('Microsoft YaHei'),
|
||||
local('Helvetica Neue');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'HarmonyOS Sans SC';
|
||||
src: local('HarmonyOS Sans SC Medium'),
|
||||
local('PingFang SC Medium');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'HarmonyOS Sans SC';
|
||||
src: local('HarmonyOS Sans SC Bold'),
|
||||
local('PingFang SC Semibold');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---------- 重置 ---------- */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: #0a0a0f;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ---------- 数字专用字体(加粗) ---------- */
|
||||
.digit,
|
||||
.big-number,
|
||||
.remote-port,
|
||||
.proxy-addr,
|
||||
.metric-value .digit,
|
||||
.metric-value .big-number,
|
||||
.login-header h1,
|
||||
.login-btn,
|
||||
.save-btn,
|
||||
.btn-confirm {
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 数字特别加粗(适用于大数字展示) */
|
||||
.big-number {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 代码/端口类数字使用等宽字体,但保持加粗 */
|
||||
.digit,
|
||||
.remote-port,
|
||||
.proxy-addr {
|
||||
font-family: 'HarmonyOS Sans SC', 'JetBrains Mono', monospace;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* ---------- 全局颜色变量 ---------- */
|
||||
:root {
|
||||
--primary-blue: #4a7cff;
|
||||
--primary-blue-dim: rgba(74, 124, 255, 0.15);
|
||||
--primary-blue-glow: rgba(74, 124, 255, 0.3);
|
||||
--primary-blue-border: rgba(74, 124, 255, 0.2);
|
||||
--bg-deep: #0a0a18;
|
||||
--bg-card: rgba(13, 13, 26, 0.6);
|
||||
--bg-metric: rgba(255, 255, 255, 0.03);
|
||||
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||
--text-dim: rgba(255, 255, 255, 0.25);
|
||||
--text-muted: rgba(255, 255, 255, 0.35);
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--text-primary: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* ---------- 滚动条 ---------- */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(74, 124, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(74, 124, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ---------- 背景磨砂层 ---------- */
|
||||
.app-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(30, 60, 120, 0.3) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 50%, rgba(80, 30, 120, 0.2) 0%, transparent 60%),
|
||||
#0a0a0f;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ---------- 主容器 ---------- */
|
||||
.app-container {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(40px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 登录态尺寸 */
|
||||
.app-container:not(.is-logged-in) {
|
||||
width: 600px;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
/* 登录后尺寸 */
|
||||
.app-container.is-logged-in {
|
||||
width: min(1600px, 92vw);
|
||||
height: min(900px, 88vh);
|
||||
}
|
||||
|
||||
/* ---------- 旋转圈 ---------- */
|
||||
.loading-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.04);
|
||||
border-top: 3px solid var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 5;
|
||||
}
|
||||
.loading-ring.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---------- 主内容淡入 ---------- */
|
||||
.main-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 28px; /* ← 补上内边距 */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.main-panel.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* ===== style-2.css - 登录页 ===== */
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 32px 48px 48px 48px; /* 上左右不变,下边距加大 */
|
||||
}
|
||||
|
||||
.login-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-primary);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.input-group label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.input-group input {
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary-blue-border);
|
||||
}
|
||||
.input-group input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 44px;
|
||||
margin-top: 6px;
|
||||
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0a0a0f;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.login-btn:hover:not(:disabled) {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 8px 24px var(--primary-blue-glow);
|
||||
}
|
||||
.login-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #f87171;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.login-form .hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
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 img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- 登录页补充优化 ---------- */
|
||||
.login-card .login-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 注册按钮距上方输入框远一点 */
|
||||
.login-card .login-form .login-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
/* ===== style-3.css - 主界面核心 ===== */
|
||||
|
||||
/* ---------- 顶部导航 ---------- */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.top-left .nav-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brand-info .logo-text {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-wrapper .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #444;
|
||||
transition: background 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-wrapper .status-dot.active {
|
||||
background: #63e2b7;
|
||||
box-shadow: 0 0 12px rgba(99, 226, 183, 0.4);
|
||||
}
|
||||
|
||||
.status-wrapper .status-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.top-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 6px 16px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 70, 70, 0.15);
|
||||
border-color: rgba(255, 70, 70, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ---------- Tab 栏 ---------- */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding: 14px 0 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
transition: color 0.3s, border-color 0.3s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--primary-blue);
|
||||
border-bottom-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ---------- 内容区 ---------- */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.06) transparent;
|
||||
}
|
||||
|
||||
.content-area::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.content-area::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- 账户管理区域 ---------- */
|
||||
.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);
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: #63e2b7;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* ---------- 统一保存按钮 ---------- */
|
||||
.save-btn,
|
||||
.save-config-btn {
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0a0a0f;
|
||||
cursor: pointer;
|
||||
transition: filter 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
/* 确保按钮本身不裁切阴影 */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.save-btn:hover,
|
||||
.save-config-btn:hover {
|
||||
filter: brightness(1.25);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.save-btn:active,
|
||||
.save-config-btn:active {
|
||||
filter: brightness(0.9);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* 账户管理中的修改密码按钮(覆盖尺寸) */
|
||||
.profile-form .save-btn {
|
||||
grid-column: auto;
|
||||
padding: 8px 20px;
|
||||
margin-top: 0;
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 保存配置按钮全宽(在全局配置页使用) */
|
||||
.save-config-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 隧道列表 ---------- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left button {
|
||||
padding: 8px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-left button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.btn-import:hover {
|
||||
border-color: var(--primary-blue-border);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.btn-export:hover {
|
||||
border-color: rgba(99, 150, 255, 0.3);
|
||||
color: #6a9fff;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--primary-blue-dim) !important;
|
||||
border-color: var(--primary-blue-border) !important;
|
||||
color: var(--primary-blue) !important;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: rgba(74, 124, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.toolbar-right .search-input {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 200px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.toolbar-right .search-input:focus {
|
||||
border-color: var(--primary-blue-border);
|
||||
}
|
||||
|
||||
.toolbar-right .search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---------- 隧道卡片 ---------- */
|
||||
.proxy-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.proxy-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.proxy-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.proxy-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.proxy-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.proxy-addr {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.proxy-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.proxy-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.remote-port {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
transition: color 0.2s;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.proxy-footer {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---------- 开关 ---------- */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.switch .slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #1a1a24;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: var(--primary-blue);
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
background: #0a0a0f;
|
||||
}
|
||||
|
||||
.switch.small {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.switch.small .slider::before {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
}
|
||||
|
||||
.switch.small input:checked + .slider::before {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
/* ---------- 状态点(只读展示) ---------- */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: #63e2b7;
|
||||
box-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
|
||||
}
|
||||
|
||||
/* ---------- 弹窗 ---------- */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
background: #1a1a24;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 20px;
|
||||
padding: 28px 32px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.dialog-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dialog-form .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dialog-form .form-row label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dialog-form .form-row input,
|
||||
.dialog-form .form-row select {
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.dialog-form .form-row input:focus,
|
||||
.dialog-form .form-row select:focus {
|
||||
border-color: var(--primary-blue-border);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-actions button {
|
||||
padding: 8px 24px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
|
||||
color: #0a0a0f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-confirm:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 16px var(--primary-blue-glow);
|
||||
}
|
||||
|
||||
/* ---------- 自定义 Select(墨蓝风格) ---------- */
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: rgba(13, 13, 26, 0.85) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 1px solid var(--primary-blue-border) !important;
|
||||
border-radius: 10px !important;
|
||||
color: #e0e0e0 !important;
|
||||
padding: 10px 14px !important;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
|
||||
width: 100%;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%234a7cff' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
padding-right: 40px !important;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: rgba(74, 124, 255, 0.4) !important;
|
||||
background: rgba(20, 20, 40, 0.9) !important;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--primary-blue) !important;
|
||||
box-shadow: 0 0 0 3px rgba(74, 124, 255, 0.15), inset 0 0 0 1px rgba(74, 124, 255, 0.05);
|
||||
}
|
||||
|
||||
select option {
|
||||
background: #0d0d1a !important;
|
||||
color: #e0e0e0 !important;
|
||||
padding: 8px 14px !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
select option:hover,
|
||||
select option:checked,
|
||||
select option:focus {
|
||||
background: rgba(74, 124, 255, 0.2) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
select:-moz-focusring {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 #e0e0e0;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- 弹窗入场/出场动画 ---------- */
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-enter-from .dialog-card,
|
||||
.dialog-leave-to .dialog-card {
|
||||
transform: scale(0.85) translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-enter-to .dialog-card,
|
||||
.dialog-leave-from .dialog-card {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- 响应式调整 ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.profile-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.proxy-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.toolbar-right .search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.proxy-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.top-left .nav-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.brand-info .logo-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
.dialog-card {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/* ===== style-4.css - v1.5 新增样式 ===== */
|
||||
|
||||
/* ---------- 配置页面 ---------- */
|
||||
.config-tab-content {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-page-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.config-page-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.config-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* 首次使用提示 */
|
||||
.config-tip {
|
||||
background: rgba(255, 200, 50, 0.08);
|
||||
border: 1px solid rgba(255, 200, 50, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: #f0c040;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---------- 配置卡片 ---------- */
|
||||
.config-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.config-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-card-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
.config-card-body .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.config-card-body .form-row label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.config-card-body .form-row input,
|
||||
.config-card-body .form-row select {
|
||||
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;
|
||||
width: 100%;
|
||||
}
|
||||
.config-card-body .form-row input:focus,
|
||||
.config-card-body .form-row select:focus {
|
||||
border-color: var(--primary-blue-border);
|
||||
}
|
||||
|
||||
/* 数字输入框填满列宽 */
|
||||
.config-card-body .form-row input[type="number"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 只读行(TCP Mux) */
|
||||
.readonly-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.readonly-value .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #63e2b7;
|
||||
box-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.readonly-value .status-text.active {
|
||||
color: #63e2b7;
|
||||
font-size: 13px;
|
||||
}
|
||||
.readonly-value .hint-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Token 输入框 + 显示按钮 */
|
||||
.token-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.token-input-wrapper input {
|
||||
flex: 1;
|
||||
}
|
||||
.token-toggle-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.token-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ---------- v2 协议开关(灰标禁用) ---------- */
|
||||
.v2-switch-row {
|
||||
grid-column: 1 / -1 !important;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.v2-switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
|
||||
/* "即将推出" 徽章 */
|
||||
.v2-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 12px;
|
||||
background: rgba(255, 200, 50, 0.12);
|
||||
border: 1px solid rgba(255, 200, 50, 0.15);
|
||||
border-radius: 12px;
|
||||
color: #f0c040;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v2-badge.v2-enabled {
|
||||
background: rgba(99, 226, 183, 0.12);
|
||||
border-color: rgba(99, 226, 183, 0.2);
|
||||
color: #63e2b7;
|
||||
}
|
||||
|
||||
.v2-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
padding: 2px 0 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- 日志面板 ---------- */
|
||||
.log-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0 12px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.log-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.log-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-info-badge {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.log-error-badge {
|
||||
font-size: 13px;
|
||||
color: #f87171;
|
||||
}
|
||||
.log-auto-scroll-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-auto-scroll-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-btn {
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.log-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 终端样式(类似命令行) */
|
||||
.log-terminal {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
}
|
||||
.log-terminal::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.log-terminal::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-terminal::-webkit-scrollbar-thumb {
|
||||
background: rgba(74, 124, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-terminal::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(74, 124, 255, 0.4);
|
||||
}
|
||||
|
||||
.log-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log-line {
|
||||
color: #c0c0c0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 日志底部状态 */
|
||||
.log-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-polling-status {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ---------- 响应式调整 ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.config-card-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.log-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.log-toolbar-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.token-input-wrapper {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.v2-switch-wrapper {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FrpsToml 对应 frps.toml 结构
|
||||
type FrpsToml struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
Auth struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"auth"`
|
||||
Log struct {
|
||||
To string `json:"to"`
|
||||
Level string `json:"level"`
|
||||
MaxDays int `json:"maxDays"`
|
||||
} `json:"log"`
|
||||
Transport struct {
|
||||
TcpMux bool `json:"tcpMux"`
|
||||
WireProtocolV2 bool `json:"wireProtocolV2"`
|
||||
} `json:"transport"`
|
||||
}
|
||||
|
||||
func ParseToml(content string) (*FrpsToml, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
result := &FrpsToml{
|
||||
Transport: struct {
|
||||
TcpMux bool `json:"tcpMux"`
|
||||
WireProtocolV2 bool `json:"wireProtocolV2"`
|
||||
}{
|
||||
TcpMux: true,
|
||||
WireProtocolV2: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
value = strings.Trim(value, `"`)
|
||||
|
||||
switch key {
|
||||
case "bindPort":
|
||||
result.BindPort, _ = strconv.Atoi(value)
|
||||
case "token":
|
||||
result.Auth.Token = value
|
||||
case "level":
|
||||
result.Log.Level = value
|
||||
case "maxDays":
|
||||
result.Log.MaxDays, _ = strconv.Atoi(value)
|
||||
case "tcpMux":
|
||||
result.Transport.TcpMux = value == "true"
|
||||
case "wireProtocol":
|
||||
result.Transport.WireProtocolV2 = value == "v2"
|
||||
}
|
||||
}
|
||||
|
||||
if result.BindPort == 0 {
|
||||
return nil, fmt.Errorf("未找到 bindPort 字段")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *FrpsToml) ToGlobalConfig() *GlobalConfig {
|
||||
return &GlobalConfig{
|
||||
BindPort: f.BindPort,
|
||||
Token: f.Auth.Token,
|
||||
LogLevel: f.Log.Level,
|
||||
LogMaxDays: f.Log.MaxDays,
|
||||
TcpMux: f.Transport.TcpMux,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user