From 2e9b2f80508afec690d8709b4d87c6a64f6d0b7b Mon Sep 17 00:00:00 2001 From: mogumc Date: Wed, 27 May 2026 20:34:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84HTTP/TCP=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=B9=B6=E5=8F=91=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/http.go | 354 +++++++++++---------------- api/log.go | 49 ++-- api/socket.go | 141 +++++++++-- frontend/src/components/Settings.vue | 136 ++++++++-- frontend/src/components/WebDebug.vue | 25 +- frontend/wailsjs/go/models.ts | 32 +-- frontend/wailsjs/go/service/App.d.ts | 16 +- frontend/wailsjs/go/service/App.js | 32 ++- global/config.go | 30 ++- go.mod | 2 +- service/router.go | 110 ++++++--- 11 files changed, 580 insertions(+), 347 deletions(-) diff --git a/api/http.go b/api/http.go index ee8ca6c..49c9263 100644 --- a/api/http.go +++ b/api/http.go @@ -14,6 +14,17 @@ import ( "time" ) +// maxBodySize 限制响应体最大读取量为 50MB,防止 OOM +const maxBodySize = 50 << 20 + +// 默认 HTTP 客户端,复用连接池 +var defaultHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + }, +} + // HttpRequest 请求结构体 type HttpRequest struct { Method string `json:"method"` // HTTP 方法 @@ -64,39 +75,40 @@ type FileData struct { Path string `json:"path"` // 文件路径 } -// SendRequest 发送 HTTP 请求 -func SendRequest(req *HttpRequest) *HttpResponse { - resp := &HttpResponse{ - Headers: make(map[string]string), - } - - if req.URL == "" { - resp.Error = "请求地址不能为空" - return resp - } - - // 补全协议 +// normalizeURL 补全协议前缀 +func normalizeURL(req *HttpRequest) { if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { req.URL = "https://" + req.URL } +} - // 设置超时 +// buildHTTPClient 创建带代理和 TLS 配置的 HTTP 客户端 +// 无自定义代理且不跳过验证时复用默认客户端 +func buildHTTPClient(req *HttpRequest) *http.Client { + // 无自定义设置时复用默认客户端(共享连接池) + if req.Proxy == "" && !req.Insecure { + if req.Timeout > 0 { + return &http.Client{ + Timeout: time.Duration(req.Timeout) * time.Second, + Transport: defaultHTTPClient.Transport, + } + } + return defaultHTTPClient + } + + // 有自定义设置时创建新客户端 timeout := time.Duration(req.Timeout) * time.Second if timeout == 0 { timeout = 30 * time.Second } - // 创建 HTTP 客户端 - client := &http.Client{ - Timeout: timeout, - } + client := &http.Client{Timeout: timeout} - // 配置代理 if req.Proxy != "" { proxyURL, err := url.Parse(req.Proxy) if err != nil { - resp.Error = fmt.Sprintf("代理地址格式错误: %v", err) - return resp + // 代理格式错误,降级为默认客户端 + return defaultHTTPClient } client.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyURL), @@ -106,72 +118,108 @@ func SendRequest(req *HttpRequest) *HttpResponse { } } else if req.Insecure { client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } - // 创建请求体 + return client +} + +// setupCookies 将请求中的 Cookie 设置到客户端 Jar +func setupCookies(client *http.Client, req *HttpRequest) { + if len(req.Cookies) == 0 { + return + } + cookieJar, _ := cookiejar.New(nil) + var cookieList []*http.Cookie + for name, value := range req.Cookies { + cookieList = append(cookieList, &http.Cookie{Name: name, Value: value}) + } + parsedURL, _ := url.Parse(req.URL) + cookieJar.SetCookies(parsedURL, cookieList) + client.Jar = cookieJar +} + +// setRequestHeaders 设置自定义请求头(host 单独处理) +func setRequestHeaders(httpReq *http.Request, req *HttpRequest) { + for key, value := range req.Headers { + if strings.ToLower(key) == "host" { + httpReq.Host = value + } else { + httpReq.Header.Set(key, value) + } + } +} + +// parseHTTPResponse 从 http.Response 解析到 HttpResponse +func parseHTTPResponse(httpResp *http.Response, resp *HttpResponse, start time.Time) { + resp.Time = time.Since(start).String() + + // 限制最大读取量防止 OOM + body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxBodySize)) + if err != nil { + resp.Error = fmt.Sprintf("读取响应失败: %v", err) + return + } + + resp.StatusCode = httpResp.StatusCode + resp.Status = httpResp.Status + resp.Body = string(body) + resp.ContentLength = httpResp.ContentLength + resp.Size = int64(len(body)) + resp.ContentType = httpResp.Header.Get("Content-Type") + + resp.Headers = make(map[string]string) + for key, values := range httpResp.Header { + resp.Headers[key] = strings.Join(values, "; ") + } + + resp.Cookies = convertCookies(httpResp.Cookies()) +} + +// SendRequest 发送 HTTP 请求 +func SendRequest(req *HttpRequest) *HttpResponse { + resp := &HttpResponse{} + + if req.URL == "" { + resp.Error = "请求地址不能为空" + return resp + } + + normalizeURL(req) + + client := buildHTTPClient(req) + + // 构造请求体 var bodyReader io.Reader + var err error contentType := req.ContentType if req.Method != http.MethodGet && req.Method != http.MethodHead { switch req.BodyType { case "file": - // 文件上传 if req.FilePath != "" { - file, err := os.Open(req.FilePath) + bodyReader, contentType, err = buildFileBody(req.FilePath) if err != nil { - resp.Error = fmt.Sprintf("打开文件失败: %v", err) + resp.Error = err.Error() return resp } - defer file.Close() - - // 获取文件信息 - fileInfo, err := file.Stat() - if err != nil { - resp.Error = fmt.Sprintf("获取文件信息失败: %v", err) - return resp - } - - // 使用 multipart 上传 - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", fileInfo.Name()) - if err != nil { - resp.Error = fmt.Sprintf("创建表单文件失败: %v", err) - return resp - } - if _, err := io.Copy(part, file); err != nil { - resp.Error = fmt.Sprintf("复制文件内容失败: %v", err) - return resp - } - writer.Close() - bodyReader = body - contentType = writer.FormDataContentType() } - case "form": - // 表单数据 if req.Body != "" { bodyReader = strings.NewReader(req.Body) if contentType == "" { contentType = "application/x-www-form-urlencoded" } } - case "json": - // JSON 数据 if req.Body != "" { bodyReader = strings.NewReader(req.Body) if contentType == "" { contentType = "application/json" } } - default: - // 文本数据 if req.Body != "" { bodyReader = strings.NewReader(req.Body) if contentType == "" { @@ -181,46 +229,20 @@ func SendRequest(req *HttpRequest) *HttpResponse { } } - // 创建请求 httpReq, err := http.NewRequest(req.Method, req.URL, bodyReader) if err != nil { resp.Error = fmt.Sprintf("创建请求失败: %v", err) return resp } - // 设置 Content-Type if contentType != "" { httpReq.Header.Set("Content-Type", contentType) } - // 设置自定义请求头(需要单独处理 host) - for key, value := range req.Headers { - if strings.ToLower(key) == "host" { - httpReq.Host = value - } else { - httpReq.Header.Set(key, value) - } - } + setRequestHeaders(httpReq, req) + setupCookies(client, req) - // 设置 Cookie - if len(req.Cookies) > 0 { - cookieJar, _ := cookiejar.New(nil) - var cookieList []*http.Cookie - for name, value := range req.Cookies { - cookieList = append(cookieList, &http.Cookie{ - Name: name, - Value: value, - }) - } - parsedURL, _ := url.Parse(req.URL) - cookieJar.SetCookies(parsedURL, cookieList) - client.Jar = cookieJar - } - - // 记录开始时间 start := time.Now() - - // 发送请求 httpResp, err := client.Do(httpReq) if err != nil { resp.Error = fmt.Sprintf("请求失败: %v", err) @@ -228,95 +250,31 @@ func SendRequest(req *HttpRequest) *HttpResponse { } defer httpResp.Body.Close() - // 记录耗时 - resp.Time = time.Since(start).String() - - // 读取响应体 - body, err := io.ReadAll(httpResp.Body) - if err != nil { - resp.Error = fmt.Sprintf("读取响应失败: %v", err) - return resp - } - - // 设置响应 - resp.StatusCode = httpResp.StatusCode - resp.Status = httpResp.Status - resp.Body = string(body) - resp.ContentLength = httpResp.ContentLength - resp.Size = int64(len(body)) - - // 获取 Content-Type - resp.ContentType = httpResp.Header.Get("Content-Type") - - // 获取响应头 - for key, values := range httpResp.Header { - resp.Headers[key] = strings.Join(values, "; ") - } - - // 获取响应 Cookie - resp.Cookies = convertCookies(httpResp.Cookies()) - + parseHTTPResponse(httpResp, resp, start) return resp } -// SendRequestWithFiles 带文件的请求 +// SendRequestWithFiles 带多文件上传的请求 func SendRequestWithFiles(req *HttpRequest, files []FileData) *HttpResponse { - resp := &HttpResponse{ - Headers: make(map[string]string), - } + resp := &HttpResponse{} if req.URL == "" { resp.Error = "请求地址不能为空" return resp } - // 补全协议 - if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { - req.URL = "https://" + req.URL - } + normalizeURL(req) - // 设置超时 - timeout := time.Duration(req.Timeout) * time.Second - if timeout == 0 { - timeout = 30 * time.Second - } + client := buildHTTPClient(req) - // 创建 HTTP 客户端 - client := &http.Client{ - Timeout: timeout, - } - - // 配置代理 - if req.Proxy != "" { - proxyURL, err := url.Parse(req.Proxy) - if err != nil { - resp.Error = fmt.Sprintf("代理地址格式错误: %v", err) - return resp - } - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: req.Insecure, - }, - } - } else if req.Insecure { - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - } - } - - // 创建 multipart 请求 + // 构造 multipart 请求体 body := &bytes.Buffer{} writer := multipart.NewWriter(body) - // 添加文本字段 if req.Body != "" { _ = writer.WriteField("text", req.Body) } - // 添加文件 for _, file := range files { f, err := os.Open(file.Path) if err != nil { @@ -339,44 +297,17 @@ func SendRequestWithFiles(req *HttpRequest, files []FileData) *HttpResponse { writer.Close() - // 创建请求 httpReq, err := http.NewRequest(req.Method, req.URL, body) if err != nil { resp.Error = fmt.Sprintf("创建请求失败: %v", err) return resp } - // 设置 Content-Type httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + setRequestHeaders(httpReq, req) + setupCookies(client, req) - // 设置自定义请求头(需要单独处理 host) - for key, value := range req.Headers { - if strings.ToLower(key) == "host" { - httpReq.Host = value - } else { - httpReq.Header.Set(key, value) - } - } - - // 设置 Cookie - if len(req.Cookies) > 0 { - cookieJar, _ := cookiejar.New(nil) - var cookieList []*http.Cookie - for name, value := range req.Cookies { - cookieList = append(cookieList, &http.Cookie{ - Name: name, - Value: value, - }) - } - parsedURL, _ := url.Parse(req.URL) - cookieJar.SetCookies(parsedURL, cookieList) - client.Jar = cookieJar - } - - // 记录开始时间 start := time.Now() - - // 发送请求 httpResp, err := client.Do(httpReq) if err != nil { resp.Error = fmt.Sprintf("请求失败: %v", err) @@ -384,37 +315,36 @@ func SendRequestWithFiles(req *HttpRequest, files []FileData) *HttpResponse { } defer httpResp.Body.Close() - // 记录耗时 - resp.Time = time.Since(start).String() - - // 读取响应体 - respBody, err := io.ReadAll(httpResp.Body) - if err != nil { - resp.Error = fmt.Sprintf("读取响应失败: %v", err) - return resp - } - - // 设置响应 - resp.StatusCode = httpResp.StatusCode - resp.Status = httpResp.Status - resp.Body = string(respBody) - resp.ContentLength = httpResp.ContentLength - resp.Size = int64(len(respBody)) - - // 获取 Content-Type - resp.ContentType = httpResp.Header.Get("Content-Type") - - // 获取响应头 - for key, values := range httpResp.Header { - resp.Headers[key] = strings.Join(values, "; ") - } - - // 获取响应 Cookie - resp.Cookies = convertCookies(httpResp.Cookies()) - + parseHTTPResponse(httpResp, resp, start) return resp } +// buildFileBody 构造单文件上传的 multipart 请求体 +func buildFileBody(filePath string) (io.Reader, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", fmt.Errorf("打开文件失败: %v", err) + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return nil, "", fmt.Errorf("获取文件信息失败: %v", err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", fileInfo.Name()) + if err != nil { + return nil, "", fmt.Errorf("创建表单文件失败: %v", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, "", fmt.Errorf("复制文件内容失败: %v", err) + } + writer.Close() + return body, writer.FormDataContentType(), nil +} + // ParseHeaders 解析请求头字符串 func ParseHeaders(headerStr string) map[string]string { headers := make(map[string]string) diff --git a/api/log.go b/api/log.go index 04927c9..5e5fef0 100644 --- a/api/log.go +++ b/api/log.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" ) @@ -32,13 +33,17 @@ type RequestLog struct { // LogManager 日志管理器 type LogManager struct { + mu sync.RWMutex logDir string logs []*RequestLog maxLogs int fileName string } -var logManager *LogManager +var ( + logManager *LogManager + logManagerOnce sync.Once +) // InitLogManager 初始化日志管理器 func InitLogManager(logDir string) { @@ -51,11 +56,13 @@ func InitLogManager(logDir string) { logManager.loadLogs() } -// GetLogManager 获取日志管理器 +// GetLogManager 获取日志管理器(并发安全的单例) func GetLogManager() *LogManager { - if logManager == nil { - InitLogManager("logs") - } + logManagerOnce.Do(func() { + if logManager == nil { + InitLogManager("logs") + } + }) return logManager } @@ -97,6 +104,9 @@ func (m *LogManager) saveLogs() error { // AddLog 添加日志 func (m *LogManager) AddLog(log *RequestLog) error { + m.mu.Lock() + defer m.mu.Unlock() + // 生成序号 if len(m.logs) > 0 { log.ID = m.logs[0].ID + 1 @@ -119,11 +129,15 @@ func (m *LogManager) AddLog(log *RequestLog) error { // GetLogs 获取所有日志 func (m *LogManager) GetLogs() []*RequestLog { + m.mu.RLock() + defer m.mu.RUnlock() return m.logs } // GetLog 获取单条日志 func (m *LogManager) GetLog(id int) *RequestLog { + m.mu.RLock() + defer m.mu.RUnlock() for _, log := range m.logs { if log.ID == id { return log @@ -134,6 +148,8 @@ func (m *LogManager) GetLog(id int) *RequestLog { // DeleteLog 删除日志 func (m *LogManager) DeleteLog(id int) error { + m.mu.Lock() + defer m.mu.Unlock() for i, log := range m.logs { if log.ID == id { m.logs = append(m.logs[:i], m.logs[i+1:]...) @@ -145,12 +161,16 @@ func (m *LogManager) DeleteLog(id int) error { // ClearLogs 清空日志 func (m *LogManager) ClearLogs() error { + m.mu.Lock() + defer m.mu.Unlock() m.logs = make([]*RequestLog, 0) return m.saveLogs() } // SearchLogs 搜索日志 func (m *LogManager) SearchLogs(keyword string) []*RequestLog { + m.mu.RLock() + defer m.mu.RUnlock() var result []*RequestLog keyword = strings.ToLower(keyword) @@ -171,17 +191,14 @@ func CreateLogFromResponse(req *HttpRequest, resp *HttpResponse) *RequestLog { // 解析 URL host := "" path := req.URL - if strings.HasPrefix(req.URL, "http://") { - parts := strings.SplitN(strings.TrimPrefix(req.URL, "http://"), "/", 2) - host = parts[0] - if len(parts) > 1 { - path = "/" + parts[1] - } - } else if strings.HasPrefix(req.URL, "https://") { - parts := strings.SplitN(strings.TrimPrefix(req.URL, "https://"), "/", 2) - host = parts[0] - if len(parts) > 1 { - path = "/" + parts[1] + for _, prefix := range []string{"http://", "https://"} { + if strings.HasPrefix(req.URL, prefix) { + parts := strings.SplitN(strings.TrimPrefix(req.URL, prefix), "/", 2) + host = parts[0] + if len(parts) > 1 { + path = "/" + parts[1] + } + break } } diff --git a/api/socket.go b/api/socket.go index 6d5c991..42bf6ea 100644 --- a/api/socket.go +++ b/api/socket.go @@ -11,6 +11,7 @@ import ( "webhelper/global" "github.com/gorilla/websocket" + "golang.org/x/net/proxy" ) // WebSocketClient WebSocket 客户端 @@ -27,6 +28,7 @@ type WebSocketClient struct { type WebSocketConfig struct { URL string `json:"url"` Headers map[string]string `json:"headers"` + Proxy string `json:"proxy"` // 代理地址,支持 http:// 和 socks5:// Insecure bool `json:"insecure"` } @@ -37,7 +39,10 @@ type WebSocketMessage struct { Time string `json:"time"` } -var wsClient *WebSocketClient +var ( + wsClient *WebSocketClient + wsClientOnce sync.Once +) // NewWebSocketClient 创建 WebSocket 客户端 func NewWebSocketClient() *WebSocketClient { @@ -67,13 +72,23 @@ func (c *WebSocketClient) Connect(config *WebSocketConfig, onMessage func(string header.Set(key, value) } - // 创建 Dialer(最简配置) + // 创建 Dialer dialer := &websocket.Dialer{ HandshakeTimeout: 10 * time.Second, } + // 配置代理 + proxyAddr := global.NormalizeProxy(config.Proxy) + if proxyAddr != "" { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return fmt.Errorf("代理地址格式错误: %v", err) + } + dialer.Proxy = http.ProxyURL(proxyURL) + } + // 连接 - global.Log.Infof("正在连接 WebSocket: %s", u.String()) + global.Log.Infof("正在连接 WebSocket: %s (proxy=%s)", u.String(), proxyAddr) conn, _, err := dialer.Dial(u.String(), header) if err != nil { return fmt.Errorf("连接失败: %v", err) @@ -150,11 +165,11 @@ func (c *WebSocketClient) IsConnected() bool { return c.isConnected } -// GetWebSocketClient 获取 WebSocket 客户端 +// GetWebSocketClient 获取 WebSocket 客户端(并发安全的单例) func GetWebSocketClient() *WebSocketClient { - if wsClient == nil { + wsClientOnce.Do(func() { wsClient = NewWebSocketClient() - } + }) return wsClient } @@ -194,8 +209,9 @@ type TCPClient struct { // TCPConfig TCP 配置 type TCPConfig struct { - Host string `json:"host"` - Port int `json:"port"` + Host string `json:"host"` + Port int `json:"port"` + Proxy string `json:"proxy"` // 代理地址,支持 socks5:// } // TCPMessage TCP 消息 @@ -205,7 +221,10 @@ type TCPMessage struct { Time string `json:"time"` } -var tcpClient *TCPClient +var ( + tcpClient *TCPClient + tcpClientOnce sync.Once +) // NewTCPClient 创建 TCP 客户端 func NewTCPClient() *TCPClient { @@ -224,7 +243,43 @@ func (c *TCPClient) Connect(config *TCPConfig, onMessage func(string), onError f } addr := fmt.Sprintf("%s:%d", config.Host, config.Port) - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + + // 配置代理 + proxyAddr := global.NormalizeProxy(config.Proxy) + var conn net.Conn + var err error + + if proxyAddr != "" { + // 通过代理连接 + proxyURL, parseErr := url.Parse(proxyAddr) + if parseErr != nil { + return fmt.Errorf("代理地址格式错误: %v", parseErr) + } + + switch proxyURL.Scheme { + case "socks5", "socks4": + // SOCKS 代理 + auth := &proxy.Auth{} + if proxyURL.User != nil { + auth.User = proxyURL.User.Username() + if p, ok := proxyURL.User.Password(); ok { + auth.Password = p + } + } + dialer, proxyErr := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if proxyErr != nil { + return fmt.Errorf("创建 SOCKS 代理失败: %v", proxyErr) + } + conn, err = dialer.Dial("tcp", addr) + default: + // HTTP 代理(通过 CONNECT 隧道) + conn, err = httpConnectProxy(addr, proxyAddr) + } + } else { + // 直连 + conn, err = net.DialTimeout("tcp", addr, 10*time.Second) + } + if err != nil { return fmt.Errorf("连接失败: %v", err) } @@ -235,12 +290,70 @@ func (c *TCPClient) Connect(config *TCPConfig, onMessage func(string), onError f c.onError = onError c.onClose = onClose + global.Log.Infof("TCP 连接成功: %s (proxy=%s)", addr, proxyAddr) + // 启动消息监听 go c.readMessages() return nil } +// httpConnectProxy 通过 HTTP CONNECT 代理建立 TCP 连接 +func httpConnectProxy(target, proxyAddr string) (net.Conn, error) { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + + // 连接代理 + conn, err := net.DialTimeout("tcp", proxyURL.Host, 10*time.Second) + if err != nil { + return nil, err + } + + // 发送 CONNECT 请求 + connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) + _, err = conn.Write([]byte(connectReq)) + if err != nil { + conn.Close() + return nil, err + } + + // 读取响应 + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + conn.Close() + return nil, err + } + + // 检查 200 状态 + resp := string(buf[:n]) + if !contains(resp, "200") { + conn.Close() + return nil, fmt.Errorf("代理连接失败: %s", resp) + } + + return conn, nil +} + +// contains 检查字符串是否包含子串 +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// tcpBufSize TCP 读取缓冲区大小,64KB 覆盖绝大多数调试场景 +const tcpBufSize = 64 << 10 + // readMessages 读取消息 func (c *TCPClient) readMessages() { defer func() { @@ -252,7 +365,7 @@ func (c *TCPClient) readMessages() { } }() - buf := make([]byte, 4096) + buf := make([]byte, tcpBufSize) for { n, err := c.conn.Read(buf) if err != nil { @@ -300,11 +413,11 @@ func (c *TCPClient) IsConnected() bool { return c.isConnected } -// GetTCPClient 获取 TCP 客户端 +// GetTCPClient 获取 TCP 客户端(并发安全的单例) func GetTCPClient() *TCPClient { - if tcpClient == nil { + tcpClientOnce.Do(func() { tcpClient = NewTCPClient() - } + }) return tcpClient } diff --git a/frontend/src/components/Settings.vue b/frontend/src/components/Settings.vue index 9a14111..70e775f 100644 --- a/frontend/src/components/Settings.vue +++ b/frontend/src/components/Settings.vue @@ -1,30 +1,51 @@ @@ -62,7 +110,10 @@ const resetSettings = () => { {{ t('settings.title') }} - + + + {{ t('settings.form.appearance_divider') }} + { - {{ t('settings.form.proxy_divider') }} + + {{ t('settings.form.network_divider') }} - - + + +
{{ t('settings.form.proxy_tip') }}
- - + + + {{ t('settings.form.timeout_unit') }} - - + + {{ t('settings.form.log_divider') }} + + + + + + {{ t('settings.form.log_level_tip') }} @@ -119,4 +189,22 @@ const resetSettings = () => { .settings-form { max-width: 600px; } + +.form-tip { + font-size: 12px; + color: #909399; + margin-top: 4px; + line-height: 1.4; +} + +.form-unit { + margin-left: 8px; + color: #606266; +} + +.form-tip-inline { + margin-left: 8px; + font-size: 12px; + color: #909399; +} diff --git a/frontend/src/components/WebDebug.vue b/frontend/src/components/WebDebug.vue index 6a887e0..994b101 100644 --- a/frontend/src/components/WebDebug.vue +++ b/frontend/src/components/WebDebug.vue @@ -1,13 +1,14 @@