diff --git a/internal/agent/frps_page.go b/internal/agent/frps_page.go new file mode 100644 index 0000000..809727b --- /dev/null +++ b/internal/agent/frps_page.go @@ -0,0 +1,80 @@ +package agent + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" +) + +// frps 的 custom404Page 只认 VPS 上的文件路径,所以「自定义 404 页面内容」必须先把内容 +// 落到 VPS 上。这里给 Havline 一个受控的写文件入口:只写绝对路径、只写 .html,避免变成 +// 任意文件写入。 + +type frpsPageRequest struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func (s *Server) putFRPS404Page(w http.ResponseWriter, r *http.Request) { + var req frpsPageRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + path, err := validatePagePath(req.Path) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if strings.TrimSpace(req.Content) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "页面内容为空"}) + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + if err := atomicWrite(path, []byte(req.Content), 0o644); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path, "bytes": len(req.Content)}) +} + +func (s *Server) deleteFRPS404Page(w http.ResponseWriter, r *http.Request) { + path, err := validatePagePath(r.URL.Query().Get("path")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// validatePagePath 只接受绝对路径 + .html/.htm 结尾,防止这个接口被当成任意文件写入。 +func validatePagePath(raw string) (string, error) { + path := strings.TrimSpace(raw) + // agent 只跑在 Linux 上,这里按 POSIX 绝对路径判断(用 filepath.IsAbs 会在别的平台判错) + if path == "" || !strings.HasPrefix(path, "/") { + return "", errors.New("页面路径必须是绝对路径") + } + if strings.Contains(path, "..") { + return "", errors.New("页面路径不允许包含 ..") + } + switch strings.ToLower(filepath.Ext(path)) { + case ".html", ".htm": + return filepath.Clean(path), nil + default: + return "", errors.New("页面文件必须以 .html 或 .htm 结尾") + } +} diff --git a/internal/agent/frps_page_test.go b/internal/agent/frps_page_test.go new file mode 100644 index 0000000..3c3c240 --- /dev/null +++ b/internal/agent/frps_page_test.go @@ -0,0 +1,19 @@ +package agent + +import "testing" + +// 404 页面写入接口只接受绝对路径 + .html/.htm,避免被当成任意文件写入。 +func TestValidatePagePath(t *testing.T) { + ok := []string{"/etc/frp/havline-404.html", "/var/www/404.htm"} + for _, path := range ok { + if _, err := validatePagePath(path); err != nil { + t.Errorf("%s 应当通过:%v", path, err) + } + } + bad := []string{"", "etc/frp/404.html", "/etc/frp/404.txt", "/etc/../etc/passwd.html"} + for _, path := range bad { + if _, err := validatePagePath(path); err == nil { + t.Errorf("%s 应当被拒绝", path) + } + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index 94f61c9..9422a0c 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -31,7 +31,7 @@ import ( "github.com/havline/havline/internal/sysinfo" ) -const AgentVersion = "0.15.4" +const AgentVersion = "0.15.5" // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -218,6 +218,8 @@ func agentRoutes(s *Server) []agentRoute { {http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig}, {http.MethodPost, "/api/v1/frps/install", s.installFRPS}, {http.MethodPost, "/api/v1/frps/action", s.frpsAction}, + {http.MethodPut, "/api/v1/frps/404-page", s.putFRPS404Page}, + {http.MethodDelete, "/api/v1/frps/404-page", s.deleteFRPS404Page}, {http.MethodPost, "/api/v1/certs", s.putCertificate}, {http.MethodGet, "/api/v1/metrics", s.metrics}, {http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs}, diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index f84d1f6..dce16be 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -985,6 +985,15 @@ func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, h.svc.Status(r.Context())) } +// Restart 重启全部 frpc:比「停止 → 启动」更稳,重启后会重建 Agent 隧道。 +func (h *FRPMultiHandler) Restart(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Restart(r.Context()); err != nil { + writeError(r, w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, h.svc.Status(r.Context())) +} + func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) { lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0))) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index 38a3aae..f443e9f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -203,6 +203,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs) protect("POST /api/frpmulti/start", frpHandler.Start) protect("POST /api/frpmulti/stop", frpHandler.Stop) + protect("POST /api/frpmulti/restart", frpHandler.Restart) protect("POST /api/frpmulti/reload", frpHandler.Reload) protect("GET /api/frpmulti/logs", frpHandler.Logs) protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig) diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index f0c77a0..57b9e84 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -439,6 +439,20 @@ func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil) } +// PushFRPS404Page 把自定义 404 页面内容写到 VPS 上的指定路径。 +// frps 的 custom404Page 只认文件路径,所以内容必须先落到 VPS 上。 +func (c *AgentClient) PushFRPS404Page(ctx context.Context, path, content string) error { + var result map[string]any + return c.request(ctx, http.MethodPut, "/api/v1/frps/404-page", + map[string]string{"path": path, "content": content}, &result) +} + +// DeleteFRPS404Page 移除 VPS 上的自定义 404 页面文件(文件不存在也算成功)。 +func (c *AgentClient) DeleteFRPS404Page(ctx context.Context, path string) error { + var result map[string]any + return c.request(ctx, http.MethodDelete, "/api/v1/frps/404-page?path="+url.QueryEscape(path), nil, &result) +} + func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error { return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{ "domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey, diff --git a/internal/frp/agentbin/havline-agent-linux-amd64 b/internal/frp/agentbin/havline-agent-linux-amd64 index 63caf54..3a550e6 100644 Binary files a/internal/frp/agentbin/havline-agent-linux-amd64 and b/internal/frp/agentbin/havline-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/havline-agent-linux-arm64 b/internal/frp/agentbin/havline-agent-linux-arm64 index 6e36edd..49212ff 100644 Binary files a/internal/frp/agentbin/havline-agent-linux-arm64 and b/internal/frp/agentbin/havline-agent-linux-arm64 differ diff --git a/internal/frp/frps_404_test.go b/internal/frp/frps_404_test.go new file mode 100644 index 0000000..ab74d74 --- /dev/null +++ b/internal/frp/frps_404_test.go @@ -0,0 +1,67 @@ +package frp + +import ( + "strings" + "testing" +) + +// 自定义 404 页面:填了「页面内容」就用 Havline 托管的那份(用户不用自己去 VPS 放文件), +// 只有手填路径时才用路径,两者都没填就不输出 custom404Page。 +func TestBuildFRPSServerConfigCustom404Page(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + base := func() ServerOptions { + return ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"} + } + manualPath := base() + manualPath.Custom404Page = "/var/www/404.html" + managed := base() + managed.Custom404HTML = "404" + both := base() + both.Custom404Page = "/var/www/404.html" + both.Custom404HTML = "404" + + cases := []struct { + name string + key string + options ServerOptions + want string + notWant string + }{ + {name: "都没填", key: "none", options: base(), notWant: "custom404Page"}, + {name: "只填路径", key: "path", options: manualPath, want: `custom404Page = "/var/www/404.html"`}, + { + name: "填了页面内容", key: "html", options: managed, + want: `custom404Page = "/etc/frp/havline-404.html"`, + notWant: "/var/www/404.html", + }, + { + name: "两者都填时以内容为准", key: "both", options: both, + want: `custom404Page = "/etc/frp/havline-404.html"`, + notWant: `custom404Page = "/var/www/404.html"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := baseVhostServerInput() + // 每个子用例用独立名字,避免同一份测试库里重名 + in.Name = "vps-" + tc.key + in.Options = tc.options + server, err := svc.CreateServer(ctx, in) + if err != nil { + t.Fatalf("创建服务端失败:%v", err) + } + content, err := svc.buildFRPSServerConfig(ctx, server) + if err != nil { + t.Fatalf("生成 frps.toml 失败:%v", err) + } + if tc.want != "" && !strings.Contains(content, tc.want) { + t.Fatalf("期望包含 %q:\n%s", tc.want, content) + } + if tc.notWant != "" && strings.Contains(content, tc.notWant) { + t.Fatalf("不应包含 %q:\n%s", tc.notWant, content) + } + }) + } +} diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go index ace8c27..b009028 100644 --- a/internal/frp/frps_template.go +++ b/internal/frp/frps_template.go @@ -11,6 +11,11 @@ import ( // buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml, // bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。 // 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。 +// FixedAgent404PagePath 是「自定义 404 页面内容」在 VPS 上的固定落盘位置。 +// 界面里填的内容由 Havline 写到这个路径,frps.toml 的 custom404Page 也指向它, +// 这样用户不需要自己去 VPS 上放文件。 +const FixedAgent404PagePath = "/etc/frp/havline-404.html" + func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) { // token 认证才需要取同一份 token;none / oidc 不涉及 token := "" @@ -77,8 +82,12 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host)) } // 自定义 404 页面(HTTP vhost 命中不到规则时的响应) - if page := strings.TrimSpace(server.Options.Custom404Page); page != "" { - b.WriteString(fmt.Sprintf("custom404Page = %q\n", page)) + // 界面里填了「页面内容」就用 Havline 下发的那份,其次才用用户自己填的 VPS 路径。 + switch { + case strings.TrimSpace(server.Options.Custom404HTML) != "": + b.WriteString(fmt.Sprintf("custom404Page = %q\n", FixedAgent404PagePath)) + case strings.TrimSpace(server.Options.Custom404Page) != "": + b.WriteString(fmt.Sprintf("custom404Page = %q\n", strings.TrimSpace(server.Options.Custom404Page))) } // tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口 if port := server.Options.TCPMuxHTTPConnectPort; port > 0 { diff --git a/internal/frp/model.go b/internal/frp/model.go index 7052d5b..2ad2ba4 100644 --- a/internal/frp/model.go +++ b/internal/frp/model.go @@ -158,8 +158,11 @@ type ServerOptions struct { VhostHTTPPort int `json:"vhost_http_port,omitempty"` VhostHTTPSPort int `json:"vhost_https_port,omitempty"` // frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志 - SubDomainHost string `json:"sub_domain_host,omitempty"` - Custom404Page string `json:"custom_404_page,omitempty"` + SubDomainHost string `json:"sub_domain_host,omitempty"` + Custom404Page string `json:"custom_404_page,omitempty"` + // Custom404HTML 是「自定义 404 页面内容」:填了就不用自己去 VPS 放文件, + // Havline 会把它写到 fixedAgent404PagePath 并让 frps.toml 指向那里。 + Custom404HTML string `json:"custom_404_html,omitempty"` TLSForce bool `json:"tls_force"` AllowPorts string `json:"allow_ports,omitempty"` MaxPortsPerClient int `json:"max_ports_per_client,omitempty"` diff --git a/internal/frp/service.go b/internal/frp/service.go index 5399980..45a9713 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -1463,6 +1463,8 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio warning := "" if server, err := s.store.GetServer(ctx, serverID); err != nil { warning = "frps 已安装,但读取服务端配置失败:" + err.Error() + } else if err := s.syncAgent404Page(ctx, client, server); err != nil { + warning = "frps 已安装,但自定义 404 页面下发失败:" + err.Error() } else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil { warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error() } else if err := client.PushFRPSConfig(ctx, content); err != nil { @@ -1494,6 +1496,10 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err if err != nil { return err } + // 先把「自定义 404 页面内容」落到 VPS 上:frps.toml 里写的是文件路径,文件必须先存在 + if err := s.syncAgent404Page(ctx, client, server); err != nil { + return err + } // 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置 content, err := s.buildFRPSServerConfig(ctx, server) if err != nil { @@ -1502,6 +1508,19 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err return client.PushFRPSConfig(ctx, content) } +// syncAgent404Page 把界面里填写的 404 页面内容写到 VPS 的固定路径;内容清空时删掉旧文件。 +func (s *Service) syncAgent404Page(ctx context.Context, client *AgentClient, server Server) error { + html := strings.TrimSpace(server.Options.Custom404HTML) + if html == "" { + // 没填内容就清掉 Havline 托管的那份,避免残留旧页面 + return client.DeleteFRPS404Page(ctx, FixedAgent404PagePath) + } + if err := client.PushFRPS404Page(ctx, FixedAgent404PagePath, html); err != nil { + return fmt.Errorf("自定义 404 页面下发失败:%w", err) + } + return nil +} + // errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外, // 没有 Nginx 的位置,公网反代(路由下发)整体不适用。 func errPrivilegedVhostNoPublicProxy() error { @@ -2316,6 +2335,31 @@ func (s *Service) Stop(ctx context.Context) error { return nil } +// Restart 重启全部 frpc:先停后起。 +// +// 比手动「停止 → 启动」多一层保障:Stop 会把 Agent 隧道一起停掉,而单独点「启动」 +// 不会重建它们;重启在这里顺带把隧道重新拉起来,避免停一次之后就再也连不上。 +func (s *Service) Restart(ctx context.Context) error { + stopErr := s.Stop(ctx) + startErr := s.Start(ctx) + if startErr != nil { + if stopErr != nil { + return fmt.Errorf("停止阶段:%v;启动阶段:%w", stopErr, startErr) + } + return startErr + } + // 隧道重建可能要等远端,放到后台跑,不占着这次请求 + tunnelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + go func() { + defer cancel() + s.StartAgentTunnels(tunnelCtx) + }() + if stopErr != nil { + s.logger.Warn("frpc 重启时停止阶段报错,但已重新启动", "module", "FRP", "error", stopErr.Error()) + } + return nil +} + func (s *Service) Reload(ctx context.Context) error { s.applyMu.Lock() defer s.applyMu.Unlock() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bff3f11..cd1a3db 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -322,6 +322,7 @@ export const api = { deleteFrpProxy: (id: number) => request(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }), startFrp: () => request('/api/frpmulti/start', { method: 'POST' }), stopFrp: () => request('/api/frpmulti/stop', { method: 'POST' }), + restartFrp: () => request('/api/frpmulti/restart', { method: 'POST' }), reloadFrp: () => request('/api/frpmulti/reload', { method: 'POST' }), getFrpLogs: (limit = 200, serverID?: number) => { const query = new URLSearchParams({ limit: String(limit) }) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e13e906..987edcf 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -406,6 +406,8 @@ export interface FrpServerOptions { sub_domain_host?: string /** HTTP vhost 404 页面路径(VPS 本地路径)*/ custom_404_page?: string + /** 自定义 404 页面内容:填了就不用自己往 VPS 放文件,Havline 会写到 /etc/frp/havline-404.html */ + custom_404_html?: string /** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/ tls_force?: boolean /** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */ diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index cc59a2f..eff3a05 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -724,6 +724,44 @@
强制 TLS(frps 只接受 TLS 连接)
+ +
+
+
+
自定义 404 页面内容
+
+ 填了这里就不用管上面的 VPS 路径:保存后点「下发配置」,Havline 会把内容写到 VPS 的 + /etc/frp/havline-404.html 并让 frps 指向它;清空即恢复默认页面。 +
+
+
+ 导入 HTML 文件 + + 清空 + +
+
+ + +
frps 写文件日志(/var/log/frps.log)
@@ -1656,7 +1694,7 @@ function defaultServerOptions(): FrpServerOptions { dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60, heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false, proxy_url: '', connect_server_local_ip: '', dns_server: '', - vhost_http_port: 8080, vhost_https_port: 8443, sub_domain_host: '', custom_404_page: '', + vhost_http_port: 8080, vhost_https_port: 8443, sub_domain_host: '', custom_404_page: '', custom_404_html: '', tls_force: false, allow_ports: '', max_ports_per_client: 0, tcpmux_http_connect_port: 0, frps_log_to_file: false, frps_log_max_days: 3, dashboard_addr: '', dashboard_port: 0, dashboard_user: '', @@ -1665,6 +1703,9 @@ function defaultServerOptions(): FrpServerOptions { } } +// 隐藏的文件选择框:导入 404 页面内容用 +const file404Input = ref(null) + const serverForm = reactive({ name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '', agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true, @@ -1916,6 +1957,28 @@ function openServerModal(server?: FrpServer) { showServerModal.value = true } +// 404 页面文件导入:只需要把内容读进表单,真正的落盘由 Havline 在下发配置时完成 +function pick404File() { + file404Input.value?.click() +} + +async function on404FilePicked(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + if (file.size > 512 * 1024) { + message.warning('404 页面文件请控制在 512 KB 以内') + return + } + try { + serverForm.options.custom_404_html = await file.text() + message.success(`已载入 ${file.name}`) + } catch { + message.error('读取文件失败') + } +} + function openServerDetail(server: FrpServer) { viewingServer.value = server detailStatus.value = { available: false, availability: 0, average_latency_ms: 0, peak_latency_ms: 0, samples: [] } @@ -3093,4 +3156,28 @@ useVisibilityPolling(() => { grid-template-columns: 1fr; } } + +/* 自定义 404 页面内容:直接写 / 导入,Havline 负责写到 VPS */ +.frp-404-block { + margin: 4px 0 var(--havline-space-4); + padding: 12px; + border: 1px solid var(--havline-border); + border-radius: var(--havline-radius-sm); + background: var(--havline-surface); +} + +.frp-404-block__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--havline-space-3); + margin-bottom: 8px; + flex-wrap: wrap; +} + +.frp-404-block__title { font-size: 13px; font-weight: 600; color: var(--havline-text); } +.frp-404-block__hint { margin-top: 2px; font-size: 12px; line-height: 1.6; color: var(--havline-text-muted); } +.frp-404-block__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.frp-404-block__editor :deep(textarea) { font-family: var(--havline-mono); font-size: 12px; line-height: 1.6; } +.frp-404-block__file { display: none; } diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 380ef06..548e735 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -54,6 +54,15 @@ 启动 frpc + + + + 重启 frpc + @@ -800,7 +809,7 @@ const proxies = ref([]) const proxyTraffic = ref>({}) const proxyTrafficErrors = ref>({}) const loadError = ref('') -const actionLoading = ref<'start' | 'stop' | 'reload' | null>(null) +const actionLoading = ref<'start' | 'stop' | 'restart' | 'reload' | null>(null) const savingProxy = ref(false) const showProxyModal = ref(false) const showExport = ref(false) @@ -1227,18 +1236,20 @@ function splitText(value: string) { return value.split(/[\n,\s]+/).map((item) => function textToMap(value: string) { return Object.fromEntries(value.split('\n').map((line) => line.split('=').map((item) => item.trim())).filter(([key, item]) => key && item)) } function mapToText(value?: Record) { return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n') } -async function runAction(action: 'start' | 'stop' | 'reload') { +async function runAction(action: 'start' | 'stop' | 'restart' | 'reload') { actionLoading.value = action try { - const methods = { start: api.startFrp, stop: api.stopFrp, reload: api.reloadFrp } + const methods = { start: api.startFrp, stop: api.stopFrp, restart: api.restartFrp, reload: api.reloadFrp } status.value = await methods[action]() - message.success(action === 'start' ? 'frpc 已启动' : action === 'stop' ? 'frpc 已停止' : '配置已应用') + const tips = { start: 'frpc 已启动', stop: 'frpc 已停止', restart: 'frpc 已重启', reload: '配置已应用' } + message.success(tips[action]) } catch (error) { message.error(error instanceof Error ? error.message : '操作失败') } finally { actionLoading.value = null; await load() } } function startClient() { return runAction('start') } function stopClient() { return runAction('stop') } +function restartClient() { return runAction('restart') } function reloadClient() { return runAction('reload') } async function openRuntimeModal() {