diff --git a/backend/app_backup_archive_import.go b/backend/app_backup_archive_import.go
index dee66083..42ce6f76 100644
--- a/backend/app_backup_archive_import.go
+++ b/backend/app_backup_archive_import.go
@@ -3,6 +3,7 @@ package backend
import (
"ant-chrome/backend/internal/backup"
"ant-chrome/backend/internal/config"
+ "ant-chrome/backend/internal/snapshot"
"encoding/json"
"fmt"
"os"
@@ -15,7 +16,7 @@ func backupExtractAndValidate(zipPath string) (string, backup.Manifest, error) {
if err != nil {
return "", backup.Manifest{}, err
}
- if err := unzipTo(zipPath, tmpDir); err != nil {
+ if err := snapshot.UnzipTo(zipPath, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
return "", backup.Manifest{}, fmt.Errorf("解压备份包失败: %w", err)
}
diff --git a/backend/app_dashboard_api.go b/backend/app_dashboard_api.go
index ec4d7fac..1964b195 100644
--- a/backend/app_dashboard_api.go
+++ b/backend/app_dashboard_api.go
@@ -1,9 +1,9 @@
package backend
import (
- goruntime "runtime"
-
+ "ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/logger"
+ goruntime "runtime"
)
func (a *App) GetDashboardStats() map[string]interface{} {
@@ -11,36 +11,19 @@ func (a *App) GetDashboardStats() map[string]interface{} {
if a.browserMgr != nil {
profiles = a.browserMgr.List()
}
- totalInstances := len(profiles)
- runningInstances := 0
- for _, profile := range profiles {
- if profile.Running {
- runningInstances++
- }
- }
-
- proxyCount := 0
- coreCount := 0
- maxProfileLimit := 20
- if a.config != nil {
- proxyCount = len(a.config.Browser.Proxies)
- coreCount = len(a.config.Browser.Cores)
- if a.config.App.MaxProfileLimit > 0 {
- maxProfileLimit = a.config.App.MaxProfileLimit
- }
- }
+ stats := browser.BuildDashboardStats(profiles, a.config)
var mem goruntime.MemStats
goruntime.ReadMemStats(&mem)
memUsedMB := float64(mem.Alloc) / 1024 / 1024
return map[string]interface{}{
- "totalInstances": totalInstances,
- "runningInstances": runningInstances,
- "proxyCount": proxyCount,
- "coreCount": coreCount,
+ "totalInstances": stats.TotalInstances,
+ "runningInstances": stats.RunningInstances,
+ "proxyCount": stats.ProxyCount,
+ "coreCount": stats.CoreCount,
"memUsedMB": int(memUsedMB),
- "maxProfileLimit": maxProfileLimit,
+ "maxProfileLimit": stats.MaxProfileLimit,
"appVersion": a.appVersion(),
}
}
@@ -81,12 +64,5 @@ func (a *App) ClearAppLogs() {
// GetRunningInstances 获取运行中实例的详细信息
func (a *App) GetRunningInstances() []BrowserProfile {
- all := a.browserMgr.List()
- result := make([]BrowserProfile, 0)
- for _, profile := range all {
- if profile.Running {
- result = append(result, profile)
- }
- }
- return result
+ return browser.RunningProfiles(a.browserMgr.List())
}
diff --git a/backend/app_filesystem_api.go b/backend/app_filesystem_api.go
index 64f6e924..6fc9739e 100644
--- a/backend/app_filesystem_api.go
+++ b/backend/app_filesystem_api.go
@@ -1,6 +1,7 @@
package backend
import (
+ "ant-chrome/backend/internal/fsutil"
"ant-chrome/backend/internal/logger"
"fmt"
"os"
@@ -14,21 +15,13 @@ import (
func (a *App) OpenUserDataDir(userDataDir string) error {
log := logger.New("Browser")
- userDataDir = strings.TrimSpace(userDataDir)
- if userDataDir == "" {
- return fmt.Errorf("用户数据目录不能为空")
+ userDataRoot := ""
+ if a.config != nil {
+ userDataRoot = a.config.Browser.UserDataRoot
}
-
- var fullPath string
- if filepath.IsAbs(userDataDir) {
- fullPath = userDataDir
- } else {
- root := strings.TrimSpace(a.config.Browser.UserDataRoot)
- if root == "" {
- root = "data"
- }
- root = a.resolveAppPath(root)
- fullPath = filepath.Join(root, userDataDir)
+ fullPath, err := fsutil.ResolveUserDataDir(a.resolveAppPath, userDataRoot, userDataDir)
+ if err != nil {
+ return err
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
@@ -57,16 +50,9 @@ func (a *App) OpenUserDataDir(userDataDir string) error {
func (a *App) OpenCorePath(corePath string) error {
log := logger.New("Browser")
- corePath = strings.TrimSpace(corePath)
- if corePath == "" {
- return fmt.Errorf("内核路径不能为空")
- }
-
- var fullPath string
- if filepath.IsAbs(corePath) {
- fullPath = corePath
- } else {
- fullPath = a.resolveAppPath(corePath)
+ fullPath, err := fsutil.ResolveExistingPath(a.resolveAppPath, corePath, "内核路径不能为空")
+ if err != nil {
+ return err
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
diff --git a/backend/app_instance_start_helpers_test.go b/backend/app_instance_start_helpers_test.go
new file mode 100644
index 00000000..fa54e5f9
--- /dev/null
+++ b/backend/app_instance_start_helpers_test.go
@@ -0,0 +1,171 @@
+package backend
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ goruntime "runtime"
+ "testing"
+ "time"
+)
+
+func mustListenLoopback(t *testing.T) net.Listener {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("监听测试端口失败: %v", err)
+ }
+
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ _ = conn.Close()
+ }
+ }()
+
+ return ln
+}
+
+func listenerPort(t *testing.T, ln net.Listener) int {
+ t.Helper()
+
+ tcpAddr, ok := ln.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatalf("解析监听地址失败: %T", ln.Addr())
+ }
+ return tcpAddr.Port
+}
+
+func shortLivedCommand() *exec.Cmd {
+ if goruntime.GOOS == "windows" {
+ return exec.Command("cmd", "/c", "exit", "0")
+ }
+ return exec.Command("sh", "-c", "exit 0")
+}
+
+func longLivedCommand(duration time.Duration) *exec.Cmd {
+ if goruntime.GOOS == "windows" {
+ seconds := int(duration / time.Second)
+ if seconds < 1 {
+ seconds = 1
+ }
+ return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1))
+ }
+ return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds()))
+}
+
+func stderrFailingCommand(message string) *exec.Cmd {
+ if goruntime.GOOS == "windows" {
+ return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message))
+ }
+ return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message))
+}
+
+func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd {
+ if goruntime.GOOS == "windows" {
+ seconds := int(holdFor / time.Second)
+ if seconds < 1 {
+ seconds = 1
+ }
+ // ping -n N waits roughly N-1 seconds on Windows.
+ return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1))
+ }
+ return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds()))
+}
+
+func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if check() {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Fatal("等待条件成立超时")
+}
+
+func freeLoopbackPort(t *testing.T) int {
+ t.Helper()
+
+ ln := mustListenLoopback(t)
+ port := listenerPort(t, ln)
+ _ = ln.Close()
+ return port
+}
+
+type devToolsTestServer struct {
+ port int
+ server *http.Server
+ done chan struct{}
+}
+
+func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("启动 DevTools 测试服务失败: %v", err)
+ }
+
+ srv := &http.Server{Handler: handler}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ _ = srv.Serve(ln)
+ }()
+
+ return &devToolsTestServer{
+ port: listenerPort(t, ln),
+ server: srv,
+ done: done,
+ }
+}
+
+func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err)
+ }
+
+ srv := &http.Server{Handler: handler}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ _ = srv.Serve(ln)
+ }()
+
+ return &devToolsTestServer{
+ port: port,
+ server: srv,
+ done: done,
+ }
+}
+
+func (s *devToolsTestServer) Close() error {
+ if s == nil || s.server == nil {
+ return nil
+ }
+ err := s.server.Close()
+ <-s.done
+ return err
+}
+
+func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) {
+ t.Helper()
+
+ content := fmt.Sprintf("%d\n/devtools/browser/test\n", port)
+ if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil {
+ t.Fatalf("写入 DevToolsActivePort 失败: %v", err)
+ }
+}
diff --git a/backend/app_instance_start_ready_test.go b/backend/app_instance_start_ready_test.go
new file mode 100644
index 00000000..664885b7
--- /dev/null
+++ b/backend/app_instance_start_ready_test.go
@@ -0,0 +1,214 @@
+package backend
+
+import (
+ "ant-chrome/backend/internal/browser"
+ "ant-chrome/backend/internal/config"
+ "net/http"
+ "os/exec"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestWaitForBrowserDebugReadyMarksProfileReady(t *testing.T) {
+ t.Parallel()
+
+ port := freeLoopbackPort(t)
+ app := NewApp("")
+ app.browserMgr = browser.NewManager(config.DefaultConfig(), "")
+ app.browserMgr.Profiles = map[string]*BrowserProfile{
+ "profile-ready": {
+ ProfileId: "profile-ready",
+ ProfileName: "Ready Browser",
+ Running: true,
+ DebugPort: port,
+ DebugReady: false,
+ RuntimeWarning: "pending",
+ LastStartAt: time.Now().Format(time.RFC3339),
+ },
+ }
+ app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
+
+ serverReady := make(chan *devToolsTestServer, 1)
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+ serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/json/version":
+ _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
+ case "/json/list":
+ _, _ = w.Write([]byte(`[{"id":"page-1"}]`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ }()
+
+ snapshot, changed := app.waitForBrowserDebugReady("profile-ready", port, 2*time.Second)
+ server := <-serverReady
+ defer server.Close()
+
+ if snapshot == nil {
+ t.Fatal("期望等待到调试接口就绪")
+ }
+ if !changed {
+ t.Fatal("期望调试接口就绪后标记实例状态变更")
+ }
+ if !snapshot.DebugReady {
+ t.Fatal("期望实例被标记为调试接口已就绪")
+ }
+ if snapshot.RuntimeWarning != "" {
+ t.Fatalf("期望调试接口就绪后清空警告,实际=%q", snapshot.RuntimeWarning)
+ }
+}
+
+func TestSanitizeManagedLaunchArgsRemovesSystemManagedFlags(t *testing.T) {
+ t.Parallel()
+
+ got, removed := sanitizeManagedLaunchArgs([]string{
+ "--lang=en-US",
+ "--remote-debugging-port=9222",
+ "--user-data-dir", "D:\\profiles\\demo",
+ "--proxy-server", "http://127.0.0.1:9000",
+ "--remote-debugging-pipe",
+ "https://example.com",
+ })
+
+ wantArgs := []string{"--lang=en-US", "https://example.com"}
+ if !reflect.DeepEqual(got, wantArgs) {
+ t.Fatalf("sanitizeManagedLaunchArgs args mismatch: got=%v want=%v", got, wantArgs)
+ }
+
+ wantRemoved := []string{
+ "--remote-debugging-port",
+ "--user-data-dir",
+ "--proxy-server",
+ "--remote-debugging-pipe",
+ }
+ if !reflect.DeepEqual(removed, wantRemoved) {
+ t.Fatalf("sanitizeManagedLaunchArgs removed mismatch: got=%v want=%v", removed, wantRemoved)
+ }
+}
+
+func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) {
+ t.Parallel()
+
+ input := []string{"--lang=en-US", "--disable-sync", "https://example.com"}
+ got, removed := sanitizeManagedLaunchArgs(input)
+ if !reflect.DeepEqual(got, input) {
+ t.Fatalf("sanitizeManagedLaunchArgs should preserve unmanaged args: got=%v want=%v", got, input)
+ }
+ if len(removed) != 0 {
+ t.Fatalf("sanitizeManagedLaunchArgs should not report managed args, got=%v", removed)
+ }
+}
+
+func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.DefaultConfig()
+ cfg.Browser.Proxies = []config.BrowserProxy{
+ {ProxyId: "stored-proxy", ProxyName: "Stored", ProxyConfig: "http://127.0.0.1:18080"},
+ {ProxyId: "runtime-proxy", ProxyName: "Runtime", ProxyConfig: "http://127.0.0.1:28080"},
+ }
+ app := NewApp("")
+ app.config = cfg
+ app.browserMgr = browser.NewManager(cfg, t.TempDir())
+ profile := &BrowserProfile{
+ ProfileId: "profile-temporary-proxy",
+ ProfileName: "Temporary Proxy",
+ ProxyId: "stored-proxy",
+ ProxyConfig: "http://127.0.0.1:18080",
+ }
+ input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "")
+
+ effectiveProxy, bridgeKey, releaseBridge, err := app.resolveBrowserStartProxy(input, profile)
+ if err != nil {
+ t.Fatalf("resolveBrowserStartProxy returned error: %v", err)
+ }
+ if effectiveProxy != "http://127.0.0.1:28080" {
+ t.Fatalf("expected temporary proxy, got %q", effectiveProxy)
+ }
+ if bridgeKey != "" || releaseBridge {
+ t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
+ }
+ if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
+ t.Fatalf("temporary proxy should not mutate profile: %+v", profile)
+ }
+
+ fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080")
+ effectiveProxy, bridgeKey, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile)
+ if err != nil {
+ t.Fatalf("fallback temporary proxy returned error: %v", err)
+ }
+ if effectiveProxy != "http://127.0.0.1:38080" {
+ t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy)
+ }
+ if bridgeKey != "" || releaseBridge {
+ t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
+ }
+ if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
+ t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile)
+ }
+}
+
+func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) {
+ t.Parallel()
+
+ got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{"https://one.example/", "https://two.example/"}, false, false)
+ want := []string{"--disable-sync", "https://one.example/", "https://two.example/"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("appendLaunchTargets mismatch: got=%v want=%v", got, want)
+ }
+}
+
+func TestAppendLaunchTargetsUsesBlankPageWhenSessionRestoreDisabled(t *testing.T) {
+ t.Parallel()
+
+ got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, false)
+ want := []string{"--disable-sync", "about:blank"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("appendLaunchTargets should fall back to about:blank: got=%v want=%v", got, want)
+ }
+}
+
+func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) {
+ t.Parallel()
+
+ got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, true)
+ want := []string{"--disable-sync"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("appendLaunchTargets should preserve session restore behavior: got=%v want=%v", got, want)
+ }
+}
+
+func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) {
+ t.Parallel()
+
+ profile := &BrowserProfile{
+ ProfileId: "profile-direct",
+ }
+
+ got := buildBrowserLaunchArgs(
+ profile,
+ `D:\profiles\direct`,
+ 9222,
+ "direct://",
+ nil,
+ nil,
+ []string{"about:blank"},
+ )
+
+ hasNoProxyServer := false
+ for _, arg := range got {
+ if arg == "--no-proxy-server" {
+ hasNoProxyServer = true
+ }
+ if arg == "--proxy-server=direct://" {
+ t.Fatalf("expected direct proxy launch args to avoid --proxy-server=direct://, got=%v", got)
+ }
+ }
+ if !hasNoProxyServer {
+ t.Fatalf("expected direct proxy to use --no-proxy-server, got=%v", got)
+ }
+}
diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go
index 1241f2a0..98be2571 100644
--- a/backend/app_instance_start_test.go
+++ b/backend/app_instance_start_test.go
@@ -4,14 +4,9 @@ import (
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
"errors"
- "fmt"
- "net"
"net/http"
- "os"
"os/exec"
- "path/filepath"
"reflect"
- goruntime "runtime"
"strings"
"testing"
"time"
@@ -367,364 +362,3 @@ func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) {
t.Fatal("waitBrowserProcess 未在调试端口关闭后结束")
}
}
-
-func TestWaitForBrowserDebugReadyMarksProfileReady(t *testing.T) {
- t.Parallel()
-
- port := freeLoopbackPort(t)
- app := NewApp("")
- app.browserMgr = browser.NewManager(config.DefaultConfig(), "")
- app.browserMgr.Profiles = map[string]*BrowserProfile{
- "profile-ready": {
- ProfileId: "profile-ready",
- ProfileName: "Ready Browser",
- Running: true,
- DebugPort: port,
- DebugReady: false,
- RuntimeWarning: "pending",
- LastStartAt: time.Now().Format(time.RFC3339),
- },
- }
- app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
-
- serverReady := make(chan *devToolsTestServer, 1)
- go func() {
- time.Sleep(200 * time.Millisecond)
- serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/json/version":
- _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
- case "/json/list":
- _, _ = w.Write([]byte(`[{"id":"page-1"}]`))
- default:
- http.NotFound(w, r)
- }
- }))
- }()
-
- snapshot, changed := app.waitForBrowserDebugReady("profile-ready", port, 2*time.Second)
- server := <-serverReady
- defer server.Close()
-
- if snapshot == nil {
- t.Fatal("期望等待到调试接口就绪")
- }
- if !changed {
- t.Fatal("期望调试接口就绪后标记实例状态变更")
- }
- if !snapshot.DebugReady {
- t.Fatal("期望实例被标记为调试接口已就绪")
- }
- if snapshot.RuntimeWarning != "" {
- t.Fatalf("期望调试接口就绪后清空警告,实际=%q", snapshot.RuntimeWarning)
- }
-}
-
-func TestSanitizeManagedLaunchArgsRemovesSystemManagedFlags(t *testing.T) {
- t.Parallel()
-
- got, removed := sanitizeManagedLaunchArgs([]string{
- "--lang=en-US",
- "--remote-debugging-port=9222",
- "--user-data-dir", "D:\\profiles\\demo",
- "--proxy-server", "http://127.0.0.1:9000",
- "--remote-debugging-pipe",
- "https://example.com",
- })
-
- wantArgs := []string{"--lang=en-US", "https://example.com"}
- if !reflect.DeepEqual(got, wantArgs) {
- t.Fatalf("sanitizeManagedLaunchArgs args mismatch: got=%v want=%v", got, wantArgs)
- }
-
- wantRemoved := []string{
- "--remote-debugging-port",
- "--user-data-dir",
- "--proxy-server",
- "--remote-debugging-pipe",
- }
- if !reflect.DeepEqual(removed, wantRemoved) {
- t.Fatalf("sanitizeManagedLaunchArgs removed mismatch: got=%v want=%v", removed, wantRemoved)
- }
-}
-
-func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) {
- t.Parallel()
-
- input := []string{"--lang=en-US", "--disable-sync", "https://example.com"}
- got, removed := sanitizeManagedLaunchArgs(input)
- if !reflect.DeepEqual(got, input) {
- t.Fatalf("sanitizeManagedLaunchArgs should preserve unmanaged args: got=%v want=%v", got, input)
- }
- if len(removed) != 0 {
- t.Fatalf("sanitizeManagedLaunchArgs should not report managed args, got=%v", removed)
- }
-}
-
-func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *testing.T) {
- t.Parallel()
-
- cfg := config.DefaultConfig()
- cfg.Browser.Proxies = []config.BrowserProxy{
- {ProxyId: "stored-proxy", ProxyName: "Stored", ProxyConfig: "http://127.0.0.1:18080"},
- {ProxyId: "runtime-proxy", ProxyName: "Runtime", ProxyConfig: "http://127.0.0.1:28080"},
- }
- app := NewApp("")
- app.config = cfg
- app.browserMgr = browser.NewManager(cfg, t.TempDir())
- profile := &BrowserProfile{
- ProfileId: "profile-temporary-proxy",
- ProfileName: "Temporary Proxy",
- ProxyId: "stored-proxy",
- ProxyConfig: "http://127.0.0.1:18080",
- }
- input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "")
-
- effectiveProxy, bridgeKey, releaseBridge, err := app.resolveBrowserStartProxy(input, profile)
- if err != nil {
- t.Fatalf("resolveBrowserStartProxy returned error: %v", err)
- }
- if effectiveProxy != "http://127.0.0.1:28080" {
- t.Fatalf("expected temporary proxy, got %q", effectiveProxy)
- }
- if bridgeKey != "" || releaseBridge {
- t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
- }
- if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
- t.Fatalf("temporary proxy should not mutate profile: %+v", profile)
- }
-
- fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080")
- effectiveProxy, bridgeKey, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile)
- if err != nil {
- t.Fatalf("fallback temporary proxy returned error: %v", err)
- }
- if effectiveProxy != "http://127.0.0.1:38080" {
- t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy)
- }
- if bridgeKey != "" || releaseBridge {
- t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
- }
- if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
- t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile)
- }
-}
-
-func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) {
- t.Parallel()
-
- got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{"https://one.example/", "https://two.example/"}, false, false)
- want := []string{"--disable-sync", "https://one.example/", "https://two.example/"}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("appendLaunchTargets mismatch: got=%v want=%v", got, want)
- }
-}
-
-func TestAppendLaunchTargetsUsesBlankPageWhenSessionRestoreDisabled(t *testing.T) {
- t.Parallel()
-
- got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, false)
- want := []string{"--disable-sync", "about:blank"}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("appendLaunchTargets should fall back to about:blank: got=%v want=%v", got, want)
- }
-}
-
-func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) {
- t.Parallel()
-
- got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, true)
- want := []string{"--disable-sync"}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("appendLaunchTargets should preserve session restore behavior: got=%v want=%v", got, want)
- }
-}
-
-func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) {
- t.Parallel()
-
- profile := &BrowserProfile{
- ProfileId: "profile-direct",
- }
-
- got := buildBrowserLaunchArgs(
- profile,
- `D:\profiles\direct`,
- 9222,
- "direct://",
- nil,
- nil,
- []string{"about:blank"},
- )
-
- hasNoProxyServer := false
- for _, arg := range got {
- if arg == "--no-proxy-server" {
- hasNoProxyServer = true
- }
- if arg == "--proxy-server=direct://" {
- t.Fatalf("expected direct proxy launch args to avoid --proxy-server=direct://, got=%v", got)
- }
- }
- if !hasNoProxyServer {
- t.Fatalf("expected direct proxy to use --no-proxy-server, got=%v", got)
- }
-}
-
-func mustListenLoopback(t *testing.T) net.Listener {
- t.Helper()
-
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("监听测试端口失败: %v", err)
- }
-
- go func() {
- for {
- conn, err := ln.Accept()
- if err != nil {
- return
- }
- _ = conn.Close()
- }
- }()
-
- return ln
-}
-
-func listenerPort(t *testing.T, ln net.Listener) int {
- t.Helper()
-
- tcpAddr, ok := ln.Addr().(*net.TCPAddr)
- if !ok {
- t.Fatalf("解析监听地址失败: %T", ln.Addr())
- }
- return tcpAddr.Port
-}
-
-func shortLivedCommand() *exec.Cmd {
- if goruntime.GOOS == "windows" {
- return exec.Command("cmd", "/c", "exit", "0")
- }
- return exec.Command("sh", "-c", "exit 0")
-}
-
-func longLivedCommand(duration time.Duration) *exec.Cmd {
- if goruntime.GOOS == "windows" {
- seconds := int(duration / time.Second)
- if seconds < 1 {
- seconds = 1
- }
- return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1))
- }
- return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds()))
-}
-
-func stderrFailingCommand(message string) *exec.Cmd {
- if goruntime.GOOS == "windows" {
- return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message))
- }
- return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message))
-}
-
-func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd {
- if goruntime.GOOS == "windows" {
- seconds := int(holdFor / time.Second)
- if seconds < 1 {
- seconds = 1
- }
- // ping -n N waits roughly N-1 seconds on Windows.
- return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1))
- }
- return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds()))
-}
-
-func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) {
- t.Helper()
-
- deadline := time.Now().Add(timeout)
- for time.Now().Before(deadline) {
- if check() {
- return
- }
- time.Sleep(100 * time.Millisecond)
- }
- t.Fatal("等待条件成立超时")
-}
-
-func freeLoopbackPort(t *testing.T) int {
- t.Helper()
-
- ln := mustListenLoopback(t)
- port := listenerPort(t, ln)
- _ = ln.Close()
- return port
-}
-
-type devToolsTestServer struct {
- port int
- server *http.Server
- done chan struct{}
-}
-
-func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer {
- t.Helper()
-
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("启动 DevTools 测试服务失败: %v", err)
- }
-
- srv := &http.Server{Handler: handler}
- done := make(chan struct{})
- go func() {
- defer close(done)
- _ = srv.Serve(ln)
- }()
-
- return &devToolsTestServer{
- port: listenerPort(t, ln),
- server: srv,
- done: done,
- }
-}
-
-func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer {
- t.Helper()
-
- ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
- if err != nil {
- t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err)
- }
-
- srv := &http.Server{Handler: handler}
- done := make(chan struct{})
- go func() {
- defer close(done)
- _ = srv.Serve(ln)
- }()
-
- return &devToolsTestServer{
- port: port,
- server: srv,
- done: done,
- }
-}
-
-func (s *devToolsTestServer) Close() error {
- if s == nil || s.server == nil {
- return nil
- }
- err := s.server.Close()
- <-s.done
- return err
-}
-
-func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) {
- t.Helper()
-
- content := fmt.Sprintf("%d\n/devtools/browser/test\n", port)
- if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil {
- t.Fatalf("写入 DevToolsActivePort 失败: %v", err)
- }
-}
diff --git a/backend/app_proxy_check_config.go b/backend/app_proxy_check_config.go
index 490c4730..fc0da590 100644
--- a/backend/app_proxy_check_config.go
+++ b/backend/app_proxy_check_config.go
@@ -1,9 +1,6 @@
package backend
import (
- "strings"
- "time"
-
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/proxy"
)
@@ -24,121 +21,21 @@ func (a *App) SaveProxyCheckSettings(settings ProxyCheckSettings) error {
if a.config == nil {
return nil
}
- settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, 15000)
- settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID)
- settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID)
- settings.Targets = normalizeProxyCheckTargets(settings.Targets)
- if len(settings.Targets) == 0 {
- settings.Targets = config.DefaultConfig().ProxyCheck.Targets
- }
- if settings.SpeedTargetID == "" {
- settings.SpeedTargetID = firstProxyCheckTargetID(settings.Targets, "speed", "")
- }
- if settings.IPHealthTargetID == "" {
- settings.IPHealthTargetID = firstProxyCheckTargetID(settings.Targets, "ip_health", "")
- }
- a.config.ProxyCheck = settings
+ a.config.ProxyCheck = proxy.NormalizeCheckSettings(settings)
return a.config.Save(a.resolveAppPath("config.yaml"))
}
func (a *App) proxySpeedTestConfig() *proxy.SpeedTestConfig {
- cfg := proxy.DefaultSpeedTestConfig
if a == nil || a.config == nil {
+ cfg := proxy.DefaultSpeedTestConfig
return &cfg
}
- target := a.proxyCheckTarget(a.config.ProxyCheck.SpeedTargetID, "speed")
- if strings.TrimSpace(target.URL) != "" {
- cfg.URLs = []string{strings.TrimSpace(target.URL)}
- }
- if target.TimeoutMs > 0 {
- cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
- }
- return &cfg
+ return proxy.BuildSpeedTestConfig(a.config.ProxyCheck)
}
func (a *App) proxyIPHealthConfig() *proxy.IPHealthConfig {
- cfg := &proxy.IPHealthConfig{Source: "ip_health"}
if a == nil || a.config == nil {
- return cfg
+ return &proxy.IPHealthConfig{Source: "ip_health"}
}
- target := a.proxyCheckTarget(a.config.ProxyCheck.IPHealthTargetID, "ip_health")
- if strings.TrimSpace(target.URL) != "" {
- cfg.URL = strings.TrimSpace(target.URL)
- }
- if strings.TrimSpace(target.ID) != "" {
- cfg.Source = strings.TrimSpace(target.ID)
- }
- if strings.TrimSpace(target.Parser) != "" {
- cfg.Parser = strings.TrimSpace(target.Parser)
- }
- if target.TimeoutMs > 0 {
- cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
- }
- return cfg
-}
-
-func (a *App) proxyCheckTarget(id string, targetType string) config.ProxyCheckTarget {
- if a == nil || a.config == nil {
- return config.ProxyCheckTarget{}
- }
- normalizedID := strings.TrimSpace(id)
- normalizedType := strings.TrimSpace(targetType)
- for _, target := range a.config.ProxyCheck.Targets {
- if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) {
- return target
- }
- }
- for _, target := range a.config.ProxyCheck.Targets {
- if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) {
- return target
- }
- }
- return config.ProxyCheckTarget{}
-}
-
-func normalizeProxyCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget {
- result := make([]config.ProxyCheckTarget, 0, len(targets))
- seen := map[string]struct{}{}
- for _, target := range targets {
- target.ID = strings.TrimSpace(target.ID)
- target.Name = strings.TrimSpace(target.Name)
- target.Type = strings.TrimSpace(target.Type)
- target.URL = strings.TrimSpace(target.URL)
- target.Parser = strings.TrimSpace(target.Parser)
- if target.ID == "" || target.URL == "" {
- continue
- }
- key := strings.ToLower(target.ID)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- if target.Name == "" {
- target.Name = target.ID
- }
- if target.Type == "" {
- target.Type = "speed"
- }
- if target.TimeoutMs <= 0 {
- target.TimeoutMs = 10000
- }
- result = append(result, target)
- }
- return result
-}
-
-func firstProxyCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string {
- for _, target := range targets {
- if strings.EqualFold(strings.TrimSpace(target.Type), targetType) {
- return strings.TrimSpace(target.ID)
- }
- }
- return fallback
-}
-
-func normalizePositiveInt(value int, fallback int) int {
- if value > 0 {
- return value
- }
- return fallback
+ return proxy.BuildIPHealthConfig(a.config.ProxyCheck)
}
diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go
index 2103b029..24a7c427 100644
--- a/backend/app_proxy_query.go
+++ b/backend/app_proxy_query.go
@@ -1,43 +1,22 @@
package backend
import (
+ "ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/proxy"
)
func (a *App) BrowserProxyList() []BrowserProxy {
- if a.browserMgr.ProxyDAO != nil {
- if list, err := a.browserMgr.ProxyDAO.List(); err == nil {
- return list
- }
- }
- return append([]BrowserProxy{}, a.config.Browser.Proxies...)
+ return browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
}
// BrowserProxyListGroups 获取所有代理分组名称
func (a *App) BrowserProxyListGroups() []string {
- if a.browserMgr.ProxyDAO != nil {
- if groups, err := a.browserMgr.ProxyDAO.ListGroups(); err == nil {
- return groups
- }
- }
- return nil
+ return browser.ListProxyGroups(a.browserMgr.ProxyDAO)
}
// BrowserProxyListByGroup 按分组名称查询代理
func (a *App) BrowserProxyListByGroup(groupName string) []BrowserProxy {
- if a.browserMgr.ProxyDAO != nil {
- if list, err := a.browserMgr.ProxyDAO.ListByGroup(groupName); err == nil {
- return list
- }
- }
-
- var result []BrowserProxy
- for _, item := range a.config.Browser.Proxies {
- if item.GroupName == groupName {
- result = append(result, item)
- }
- }
- return result
+ return browser.ListProxiesByGroupWithFallback(a.browserMgr.ProxyDAO, groupName, a.config.Browser.Proxies)
}
// ValidateProxyConfig 验证代理配置是否支持
@@ -67,10 +46,5 @@ func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult {
// getLatestProxies 获取最新的代理列表,优先从数据库读取
func (a *App) getLatestProxies() []BrowserProxy {
- if a.browserMgr.ProxyDAO != nil {
- if list, err := a.browserMgr.ProxyDAO.List(); err == nil && len(list) > 0 {
- return list
- }
- }
- return a.config.Browser.Proxies
+ return browser.LatestProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
}
diff --git a/backend/app_proxy_save.go b/backend/app_proxy_save.go
index 831b5066..abd25aba 100644
--- a/backend/app_proxy_save.go
+++ b/backend/app_proxy_save.go
@@ -3,78 +3,12 @@ package backend
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
- "strings"
+ "ant-chrome/backend/internal/proxy"
)
func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error {
log := logger.New("Browser")
- normalized := make([]BrowserProxy, 0, len(proxies))
- for i, item := range proxies {
- proxyName := strings.TrimSpace(item.ProxyName)
- proxyConfig := strings.TrimSpace(item.ProxyConfig)
- if proxyName == "" || proxyConfig == "" {
- continue
- }
- proxyID := strings.TrimSpace(item.ProxyId)
- if proxyID == "" {
- proxyID = generateUUID()
- }
- sourceURL := strings.TrimSpace(item.SourceURL)
- sourceID := strings.TrimSpace(item.SourceID)
- sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix)
- sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt)
- sourceRefreshIntervalM := item.SourceRefreshIntervalM
- if sourceRefreshIntervalM < 0 {
- sourceRefreshIntervalM = 0
- }
- if sourceRefreshIntervalM > 24*60 {
- sourceRefreshIntervalM = 24 * 60
- }
- sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != ""
- if sourceAutoRefresh && sourceRefreshIntervalM <= 0 {
- sourceRefreshIntervalM = 60
- }
- if !sourceAutoRefresh {
- sourceRefreshIntervalM = 0
- }
- if sourceURL == "" {
- sourceID = ""
- sourceNamePrefix = ""
- sourceLastRefreshAt = ""
- sourceAutoRefresh = false
- sourceRefreshIntervalM = 0
- }
- normalized = append(normalized, BrowserProxy{
- ProxyId: proxyID,
- ProxyName: proxyName,
- ProxyConfig: proxyConfig,
- DnsServers: strings.TrimSpace(item.DnsServers),
- GroupName: strings.TrimSpace(item.GroupName),
- SourceID: sourceID,
- SourceURL: sourceURL,
- SourceNamePrefix: sourceNamePrefix,
- SourceAutoRefresh: sourceAutoRefresh,
- SourceRefreshIntervalM: sourceRefreshIntervalM,
- SourceLastRefreshAt: sourceLastRefreshAt,
- SortOrder: i,
- })
- }
-
- builtins := []BrowserProxy{
- {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
- }
- for _, builtin := range builtins {
- found := false
- for _, item := range normalized {
- if item.ProxyId == builtin.ProxyId {
- found = true
- break
- }
- }
- if !found {
- normalized = append([]BrowserProxy{builtin}, normalized...)
- }
- }
+ normalized := proxy.NormalizeBrowserProxies(proxies, generateUUID)
a.config.Browser.Proxies = normalized
diff --git a/backend/app_snapshot_api.go b/backend/app_snapshot_api.go
index 15130bec..54ea1ba6 100644
--- a/backend/app_snapshot_api.go
+++ b/backend/app_snapshot_api.go
@@ -1,6 +1,7 @@
package backend
import (
+ "ant-chrome/backend/internal/snapshot"
"encoding/json"
"fmt"
"os"
@@ -48,7 +49,7 @@ func (a *App) BrowserSnapshotCreate(profileId, name string) (SnapshotInfo, error
zipPath := filepath.Join(snapDir, snapshotID+"_"+safeName+".zip")
metaPath := filepath.Join(snapDir, snapshotID+"_"+safeName+".meta.json")
- if err := zipDir(userDataDir, zipPath); err != nil {
+ if err := snapshot.ZipDir(userDataDir, zipPath); err != nil {
return SnapshotInfo{}, fmt.Errorf("压缩失败: %w", err)
}
@@ -129,7 +130,7 @@ func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error {
return err
}
- metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId)
+ metaPath, zipPath, err := snapshot.FindFiles(snapDir, snapshotId)
if err != nil {
return err
}
@@ -142,7 +143,7 @@ func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error {
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
return err
}
- return unzipTo(zipPath, userDataDir)
+ return snapshot.UnzipTo(zipPath, userDataDir)
}
// BrowserSnapshotDelete 删除快照
@@ -151,7 +152,7 @@ func (a *App) BrowserSnapshotDelete(profileId, snapshotId string) error {
if err != nil {
return err
}
- metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId)
+ metaPath, zipPath, err := snapshot.FindFiles(snapDir, snapshotId)
if err != nil {
return err
}
diff --git a/backend/app_snapshot_paths.go b/backend/app_snapshot_paths.go
index 11745837..b88c7430 100644
--- a/backend/app_snapshot_paths.go
+++ b/backend/app_snapshot_paths.go
@@ -1,36 +1,10 @@
package backend
import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
+ "ant-chrome/backend/internal/snapshot"
)
// snapshotDir 返回指定实例的快照目录路径(存放在 data/snapshots 下)
func (a *App) snapshotDir(profileId string) (string, error) {
- dir := filepath.Join(a.resolveAppPath("data"), "snapshots", profileId)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return "", err
- }
- return dir, nil
-}
-
-// findSnapshotFiles 在快照目录中找到指定 snapshotId 的 meta 和 zip 路径
-func findSnapshotFiles(snapDir, snapshotId string) (metaPath, zipPath string, err error) {
- entries, err := os.ReadDir(snapDir)
- if err != nil {
- return "", "", err
- }
- for _, entry := range entries {
- if strings.HasPrefix(entry.Name(), snapshotId) && strings.HasSuffix(entry.Name(), ".meta.json") {
- metaPath = filepath.Join(snapDir, entry.Name())
- zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip"
- if _, err := os.Stat(zipPath); err != nil {
- return "", "", fmt.Errorf("快照文件不存在: %s", zipPath)
- }
- return metaPath, zipPath, nil
- }
- }
- return "", "", fmt.Errorf("快照不存在: %s", snapshotId)
+ return snapshot.EnsureDir(a.resolveAppPath("data"), profileId)
}
diff --git a/backend/app_snapshot_test.go b/backend/app_snapshot_test.go
deleted file mode 100644
index 6c21a76c..00000000
--- a/backend/app_snapshot_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package backend
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestZipDirAndUnzipTo(t *testing.T) {
- t.Parallel()
-
- root := t.TempDir()
- src := filepath.Join(root, "src")
- dstZip := filepath.Join(root, "archive.zip")
- dstDir := filepath.Join(root, "dst")
-
- if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
- t.Fatalf("mkdir src: %v", err)
- }
- if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil {
- t.Fatalf("write source file: %v", err)
- }
-
- if err := zipDir(src, dstZip); err != nil {
- t.Fatalf("zipDir failed: %v", err)
- }
- if err := unzipTo(dstZip, dstDir); err != nil {
- t.Fatalf("unzipTo failed: %v", err)
- }
-
- data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt"))
- if err != nil {
- t.Fatalf("read extracted file: %v", err)
- }
- if string(data) != "hello" {
- t.Fatalf("extracted content = %q, want hello", string(data))
- }
-}
-
-func TestFindSnapshotFiles(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- metaPath := filepath.Join(dir, "snap-1_demo.meta.json")
- zipPath := filepath.Join(dir, "snap-1_demo.zip")
-
- if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil {
- t.Fatalf("write meta: %v", err)
- }
- if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil {
- t.Fatalf("write zip: %v", err)
- }
-
- gotMeta, gotZip, err := findSnapshotFiles(dir, "snap-1")
- if err != nil {
- t.Fatalf("findSnapshotFiles failed: %v", err)
- }
- if gotMeta != metaPath {
- t.Fatalf("meta path = %q, want %q", gotMeta, metaPath)
- }
- if gotZip != zipPath {
- t.Fatalf("zip path = %q, want %q", gotZip, zipPath)
- }
-}
diff --git a/backend/automation_script_api_helpers_test.go b/backend/automation_script_api_helpers_test.go
new file mode 100644
index 00000000..da44bc35
--- /dev/null
+++ b/backend/automation_script_api_helpers_test.go
@@ -0,0 +1,69 @@
+package backend
+
+import (
+ "archive/zip"
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+func runGitForTest(t *testing.T, workdir string, args ...string) {
+ t.Helper()
+
+ cmd := exec.Command("git", args...)
+ cmd.Dir = workdir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
+ }
+}
+
+func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ writer := zip.NewWriter(&buf)
+
+ paths := make([]string, 0, len(files))
+ for relativePath := range files {
+ paths = append(paths, relativePath)
+ }
+ sort.Strings(paths)
+
+ for _, relativePath := range paths {
+ entry, err := writer.Create(relativePath)
+ if err != nil {
+ t.Fatalf("create zip entry failed: %v", err)
+ }
+ if _, err := entry.Write([]byte(files[relativePath])); err != nil {
+ t.Fatalf("write zip entry failed: %v", err)
+ }
+ }
+
+ if err := writer.Close(); err != nil {
+ t.Fatalf("close zip writer failed: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) {
+ t.Helper()
+
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("create script library package dir failed: %v", err)
+ }
+ if strings.TrimSpace(manifest) != "" {
+ if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatalf("write script library manifest failed: %v", err)
+ }
+ }
+ if strings.TrimSpace(entry) != "" {
+ if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil {
+ t.Fatalf("write script library entry failed: %v", err)
+ }
+ }
+}
diff --git a/backend/automation_script_api_test.go b/backend/automation_script_api_test.go
index 19f32030..a05cd07b 100644
--- a/backend/automation_script_api_test.go
+++ b/backend/automation_script_api_test.go
@@ -1,20 +1,13 @@
package backend
import (
- "archive/zip"
- "bytes"
- "net/http"
- "net/http/httptest"
"os"
- "os/exec"
"path/filepath"
- "sort"
"strings"
"testing"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/browser"
- "ant-chrome/backend/internal/config"
)
func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) {
@@ -475,552 +468,3 @@ func TestAutomationScriptRefreshFromBuiltin(t *testing.T) {
t.Fatalf("expected public api config to be preserved on refresh, got %+v", refreshed.PublicAPI)
}
}
-
-func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) {
- app := NewApp(t.TempDir())
-
- sourceDir := filepath.Join(t.TempDir(), "local-dir-script")
- if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil {
- t.Fatalf("create local dir source failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{
- "name": "本地目录脚本",
- "type": "playwright-cdp",
- "entryFile": "scripts/index.cjs"
-}`), 0o644); err != nil {
- t.Fatalf("write local dir manifest failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil {
- t.Fatalf("write local dir entry failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil {
- t.Fatalf("write local dir helper failed: %v", err)
- }
-
- saved, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "refresh-local-dir",
- Name: "旧本地目录脚本",
- Type: "launch-api",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: false })",
- Source: automation.ScriptSource{
- Type: "local-dir",
- URI: sourceDir,
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- refreshed, err := app.AutomationScriptRefresh(saved.ID)
- if err != nil {
- t.Fatalf("AutomationScriptRefresh returned error: %v", err)
- }
- if refreshed == nil {
- t.Fatalf("AutomationScriptRefresh returned nil result")
- }
- if refreshed.ID != saved.ID {
- t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
- }
- if refreshed.Status != "ready" {
- t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
- }
- if refreshed.EntryFile != "scripts/index.cjs" {
- t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile)
- }
- if !strings.Contains(refreshed.ScriptText, "helper.run()") {
- t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText)
- }
-}
-
-func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) {
- app := NewApp(t.TempDir())
- libraryRoot := filepath.Join(t.TempDir(), "script-library")
-
- firstScriptDir := filepath.Join(libraryRoot, "first-script")
- writeAutomationScriptLibraryPackage(t, firstScriptDir, `{
- "name": "脚本一",
- "type": "playwright-cdp",
- "entryFile": "index.cjs"
-}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })")
-
- secondScriptDir := filepath.Join(libraryRoot, "second-script")
- if err := os.MkdirAll(secondScriptDir, 0o755); err != nil {
- t.Fatalf("create second script dir failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil {
- t.Fatalf("write second script entry failed: %v", err)
- }
-
- existing, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "existing-local-library-script",
- Name: "旧脚本一",
- Type: "launch-api",
- Status: "disabled",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: false })",
- Source: automation.ScriptSource{
- Type: "local-dir",
- URI: firstScriptDir,
- },
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Path: "library/existing-script",
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- result, err := app.importAutomationLocalLibrary(libraryRoot)
- if err != nil {
- t.Fatalf("importAutomationLocalLibrary returned error: %v", err)
- }
- if result == nil {
- t.Fatalf("importAutomationLocalLibrary returned nil result")
- }
- if result.Scanned != 2 {
- t.Fatalf("expected scanned count 2, got %d", result.Scanned)
- }
- if len(result.Imported) != 2 {
- t.Fatalf("expected two imported scripts, got %d", len(result.Imported))
- }
- if len(result.Failed) != 0 {
- t.Fatalf("expected no failed imports, got %+v", result.Failed)
- }
-
- updatedFirst, err := app.AutomationScriptGet(existing.ID)
- if err != nil {
- t.Fatalf("AutomationScriptGet returned error: %v", err)
- }
- if updatedFirst.Name != "脚本一" {
- t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name)
- }
- if updatedFirst.Status != "disabled" {
- t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status)
- }
- if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir {
- t.Fatalf("unexpected updated source: %+v", updatedFirst.Source)
- }
- if !strings.Contains(updatedFirst.ScriptText, "first-script") {
- t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText)
- }
- if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled {
- t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI)
- }
-
- allScripts, err := app.automationScriptStore().List()
- if err != nil {
- t.Fatalf("List returned error: %v", err)
- }
- if len(allScripts) != 2 {
- t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts))
- }
-}
-
-func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) {
- app := NewApp(t.TempDir())
- libraryRoot := filepath.Join(t.TempDir(), "script-library")
-
- goodDir := filepath.Join(libraryRoot, "good-script")
- writeAutomationScriptLibraryPackage(t, goodDir, `{
- "name": "好脚本",
- "type": "playwright-cdp",
- "entryFile": "index.cjs"
-}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })")
-
- badDir := filepath.Join(libraryRoot, "bad-script")
- writeAutomationScriptLibraryPackage(t, badDir, `{
- "name": "坏脚本",
- "type": "playwright-cdp",
- "entryFile": "missing.cjs"
-}`, "")
-
- result, err := app.importAutomationLocalLibrary(libraryRoot)
- if err != nil {
- t.Fatalf("importAutomationLocalLibrary returned error: %v", err)
- }
- if result == nil {
- t.Fatalf("importAutomationLocalLibrary returned nil result")
- }
- if result.Scanned != 2 {
- t.Fatalf("expected scanned count 2, got %d", result.Scanned)
- }
- if len(result.Imported) != 1 {
- t.Fatalf("expected one imported script, got %d", len(result.Imported))
- }
- if len(result.Failed) != 1 {
- t.Fatalf("expected one failed script, got %+v", result.Failed)
- }
- if result.Failed[0].Path != badDir {
- t.Fatalf("unexpected failed path: %+v", result.Failed[0])
- }
- if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") {
- t.Fatalf("unexpected failed message: %+v", result.Failed[0])
- }
-}
-
-func TestAutomationScriptRefreshFromRemote(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{
- "manifest": {
- "name": "远程刷新脚本",
- "description": "来自远程",
- "type": "playwright-cdp",
- "entryFile": "index.cjs"
- },
- "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })"
-}`))
- }))
- defer server.Close()
-
- app := NewApp(t.TempDir())
- saved, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "refresh-remote",
- Name: "旧远程脚本",
- Type: "launch-api",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: false })",
- Source: automation.ScriptSource{
- Type: "remote-url",
- URI: server.URL + "/script.json",
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- refreshed, err := app.AutomationScriptRefresh(saved.ID)
- if err != nil {
- t.Fatalf("AutomationScriptRefresh returned error: %v", err)
- }
- if refreshed == nil {
- t.Fatalf("AutomationScriptRefresh returned nil result")
- }
- if refreshed.ID != saved.ID {
- t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
- }
- if refreshed.Name != "远程刷新脚本" {
- t.Fatalf("expected remote manifest name, got %q", refreshed.Name)
- }
- if refreshed.Status != "ready" {
- t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
- }
- if !strings.Contains(refreshed.ScriptText, "source: 'remote'") {
- t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText)
- }
- if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" {
- t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
- }
-}
-
-func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) {
- app := NewApp(t.TempDir())
-
- zipData := buildAutomationZipBytesForTest(t, map[string]string{
- "automation.script.json": `{
- "name": "远程 ZIP",
- "type": "playwright-cdp",
- "entryFile": "scripts/index.cjs"
-}`,
- "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })",
- })
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/zip")
- _, _ = w.Write(zipData)
- }))
- defer server.Close()
-
- bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip")
- if err != nil {
- t.Fatalf("loadAutomationRemoteBundle returned error: %v", err)
- }
-
- if bundle.Record.Name != "远程 ZIP" {
- t.Fatalf("unexpected bundle name: %s", bundle.Record.Name)
- }
- if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" {
- t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
- }
- if !strings.Contains(bundle.Record.ScriptText, "remote-zip") {
- t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText)
- }
-}
-
-func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) {
- app := NewApp(t.TempDir())
- app.config = config.DefaultConfig()
- app.config.Automation.AllowTypeScriptBuild = true
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- _, _ = w.Write([]byte(`export async function run() {
- return { ok: true, source: 'remote-ts' }
-}`))
- }))
- defer server.Close()
-
- bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts")
- if err != nil {
- t.Fatalf("loadAutomationRemoteBundle returned error: %v", err)
- }
-
- if bundle.Record.EntryFile != "demo-script.cjs" {
- t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile)
- }
- if !strings.Contains(bundle.Record.ScriptText, "remote-ts") {
- t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText)
- }
- if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" {
- t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
- }
-}
-
-func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`export async function run() {
- return { ok: true, source: 'remote-ts-refresh' }
-}`))
- }))
- defer server.Close()
-
- app := NewApp(t.TempDir())
- app.config = config.DefaultConfig()
- app.config.Automation.AllowTypeScriptBuild = true
-
- saved, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "refresh-remote-ts",
- Name: "旧远程 TS 脚本",
- Type: "launch-api",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: false })",
- Source: automation.ScriptSource{
- Type: "remote-url",
- URI: server.URL + "/refresh-script.ts",
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- refreshed, err := app.AutomationScriptRefresh(saved.ID)
- if err != nil {
- t.Fatalf("AutomationScriptRefresh returned error: %v", err)
- }
- if refreshed.EntryFile != "refresh-script.cjs" {
- t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile)
- }
- if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") {
- t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText)
- }
- if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" {
- t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
- }
-}
-
-func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) {
- if _, err := exec.LookPath("git"); err != nil {
- t.Skip("git is not installed")
- }
-
- repoDir := filepath.Join(t.TempDir(), "automation-ts-repo")
- if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil {
- t.Fatalf("create repo dir failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{
- "name": "Git TS 导入",
- "type": "playwright-cdp",
- "entryFile": "index.ts"
-}`), 0o644); err != nil {
- t.Fatalf("write git manifest failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag'
-
-export async function run() {
- return { ok: flag, source: 'git-ts' }
-}`), 0o644); err != nil {
- t.Fatalf("write git entry file failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil {
- t.Fatalf("write git helper file failed: %v", err)
- }
-
- runGitForTest(t, repoDir, "init")
- runGitForTest(t, repoDir, "config", "user.email", "test@example.com")
- runGitForTest(t, repoDir, "config", "user.name", "Test User")
- runGitForTest(t, repoDir, "add", ".")
- runGitForTest(t, repoDir, "commit", "-m", "init")
-
- app := NewApp(t.TempDir())
- app.config = config.DefaultConfig()
- app.config.Automation.AllowTypeScriptBuild = true
-
- bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo")
- if err != nil {
- t.Fatalf("loadAutomationGitBundle returned error: %v", err)
- }
-
- if bundle.Record.Name != "Git TS 导入" {
- t.Fatalf("unexpected bundle name: %s", bundle.Record.Name)
- }
- if bundle.Record.EntryFile != "index.cjs" {
- t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile)
- }
- if !strings.Contains(bundle.Record.ScriptText, "git-ts") {
- t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText)
- }
- if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" {
- t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
- }
-}
-
-func TestAutomationScriptRefreshFromGit(t *testing.T) {
- if _, err := exec.LookPath("git"); err != nil {
- t.Skip("git is not installed")
- }
-
- repoDir := filepath.Join(t.TempDir(), "automation-repo")
- if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil {
- t.Fatalf("create repo dir failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{
- "name": "Git 刷新脚本",
- "type": "playwright-cdp",
- "entryFile": "index.cjs"
-}`), 0o644); err != nil {
- t.Fatalf("write git manifest failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil {
- t.Fatalf("write git entry file failed: %v", err)
- }
-
- runGitForTest(t, repoDir, "init")
- runGitForTest(t, repoDir, "config", "user.email", "test@example.com")
- runGitForTest(t, repoDir, "config", "user.name", "Test User")
- runGitForTest(t, repoDir, "add", ".")
- runGitForTest(t, repoDir, "commit", "-m", "init")
-
- app := NewApp(t.TempDir())
- saved, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "refresh-git",
- Name: "旧 Git 脚本",
- Type: "launch-api",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: false })",
- Source: automation.ScriptSource{
- Type: "git",
- URI: repoDir,
- Path: "scripts/demo",
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- refreshed, err := app.AutomationScriptRefresh(saved.ID)
- if err != nil {
- t.Fatalf("AutomationScriptRefresh returned error: %v", err)
- }
- if refreshed == nil {
- t.Fatalf("AutomationScriptRefresh returned nil result")
- }
- if refreshed.ID != saved.ID {
- t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
- }
- if refreshed.Name != "Git 刷新脚本" {
- t.Fatalf("expected git manifest name, got %q", refreshed.Name)
- }
- if refreshed.Status != "ready" {
- t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
- }
- if !strings.Contains(refreshed.ScriptText, "source: 'git'") {
- t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText)
- }
- if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" {
- t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
- }
-}
-
-func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) {
- app := NewApp(t.TempDir())
- saved, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "refresh-manual",
- Name: "手动脚本",
- Type: "playwright-cdp",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: "module.exports.run = async () => ({ ok: true })",
- Source: automation.ScriptSource{
- Type: "manual",
- },
- })
- if err != nil {
- t.Fatalf("AutomationScriptSave returned error: %v", err)
- }
-
- if _, err := app.AutomationScriptRefresh(saved.ID); err == nil {
- t.Fatalf("expected unsupported source refresh to fail")
- }
-}
-
-func runGitForTest(t *testing.T, workdir string, args ...string) {
- t.Helper()
-
- cmd := exec.Command("git", args...)
- cmd.Dir = workdir
- output, err := cmd.CombinedOutput()
- if err != nil {
- t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
- }
-}
-
-func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte {
- t.Helper()
-
- var buf bytes.Buffer
- writer := zip.NewWriter(&buf)
-
- paths := make([]string, 0, len(files))
- for relativePath := range files {
- paths = append(paths, relativePath)
- }
- sort.Strings(paths)
-
- for _, relativePath := range paths {
- entry, err := writer.Create(relativePath)
- if err != nil {
- t.Fatalf("create zip entry failed: %v", err)
- }
- if _, err := entry.Write([]byte(files[relativePath])); err != nil {
- t.Fatalf("write zip entry failed: %v", err)
- }
- }
-
- if err := writer.Close(); err != nil {
- t.Fatalf("close zip writer failed: %v", err)
- }
- return buf.Bytes()
-}
-
-func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) {
- t.Helper()
-
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatalf("create script library package dir failed: %v", err)
- }
- if strings.TrimSpace(manifest) != "" {
- if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil {
- t.Fatalf("write script library manifest failed: %v", err)
- }
- }
- if strings.TrimSpace(entry) != "" {
- if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil {
- t.Fatalf("write script library entry failed: %v", err)
- }
- }
-}
diff --git a/backend/automation_script_defaults.go b/backend/automation_script_defaults.go
index e66665d6..20d4614f 100644
--- a/backend/automation_script_defaults.go
+++ b/backend/automation_script_defaults.go
@@ -8,10 +8,11 @@ import (
)
const (
- automationScriptDefaultsMarkerName = "defaults-seeded-v9"
+ automationScriptDefaultsMarkerName = "defaults-seeded-v10"
)
var automationScriptDefaultsLegacyMarkerNames = []string{
+ "defaults-seeded-v9",
"defaults-seeded-v8",
"defaults-seeded-v7",
"defaults-seeded-v6",
@@ -80,14 +81,20 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro
return a.markAutomationScriptDefaultsInitialized()
}
- // Migration from v1: existing scripts are present, add any missing built-in baselines once.
+ // Migration: existing scripts are present, refresh built-in baselines and add missing ones once.
if a.automationScriptDefaultsInitializedAnyLegacy() {
- existingIDs := make(map[string]struct{}, len(items))
+ existingByID := make(map[string]automation.ScriptRecord, len(items))
for _, item := range items {
- existingIDs[item.ID] = struct{}{}
+ existingByID[item.ID] = item
}
for _, bundle := range defaults {
- if _, exists := existingIDs[bundle.Record.ID]; exists {
+ if existing, exists := existingByID[bundle.Record.ID]; exists {
+ if existing.Source.Type == "builtin" {
+ bundle.Record = mergeBuiltinDefaultScriptForMigration(existing, bundle.Record)
+ if _, err := store.ImportBundle(bundle); err != nil {
+ return err
+ }
+ }
continue
}
if _, err := store.ImportBundle(bundle); err != nil {
@@ -97,3 +104,41 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro
}
return a.markAutomationScriptDefaultsInitialized()
}
+
+func mergeBuiltinDefaultScriptForMigration(existing automation.ScriptRecord, next automation.ScriptRecord) automation.ScriptRecord {
+ next.ID = existing.ID
+ next.CreatedAt = existing.CreatedAt
+ next.Status = existing.Status
+ next.TargetConfig = existing.TargetConfig
+ next.PublicAPI.Enabled = existing.PublicAPI.Enabled
+ if existing.PublicAPI.Path != "" {
+ next.PublicAPI.Path = existing.PublicAPI.Path
+ }
+ if existing.PublicAPI.TimeoutMs > 0 {
+ next.PublicAPI.TimeoutMs = existing.PublicAPI.TimeoutMs
+ }
+ next.PublicAPI.Variables = mergeBuiltinDefaultPublicAPIVariables(
+ existing.PublicAPI.Variables,
+ next.PublicAPI.Variables,
+ )
+ return next
+}
+
+func mergeBuiltinDefaultPublicAPIVariables(existing []automation.ScriptPublicAPIVariable, next []automation.ScriptPublicAPIVariable) []automation.ScriptPublicAPIVariable {
+ existingByName := make(map[string]automation.ScriptPublicAPIVariable, len(existing))
+ for _, variable := range existing {
+ if variable.Name != "" {
+ existingByName[variable.Name] = variable
+ }
+ }
+
+ result := make([]automation.ScriptPublicAPIVariable, 0, len(next))
+ for _, variable := range next {
+ if existingVariable, ok := existingByName[variable.Name]; ok {
+ variable.DefaultValue = existingVariable.DefaultValue
+ variable.Required = existingVariable.Required
+ }
+ result = append(result, variable)
+ }
+ return result
+}
diff --git a/backend/automation_script_http_e2e_helpers_test.go b/backend/automation_script_http_e2e_helpers_test.go
new file mode 100644
index 00000000..ae728bd5
--- /dev/null
+++ b/backend/automation_script_http_e2e_helpers_test.go
@@ -0,0 +1,341 @@
+package backend
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ goruntime "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func lookupAutomationHTTPProbeNode(t *testing.T) string {
+ t.Helper()
+
+ const preferred = `D:\code\plugin\nodejs\node.exe`
+ if _, err := os.Stat(preferred); err == nil {
+ return preferred
+ }
+ return lookupAutomationTestNode(t)
+}
+
+func lookupAutomationHTTPProbeChrome(t *testing.T) string {
+ t.Helper()
+
+ const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe`
+ if _, err := os.Stat(preferred); err == nil {
+ return preferred
+ }
+ t.Skip("system chrome is not installed")
+ return ""
+}
+
+func automationHTTPRepoRoot(t *testing.T) string {
+ t.Helper()
+
+ _, file, _, ok := goruntime.Caller(0)
+ if !ok {
+ t.Fatal("resolve repo root failed")
+ }
+ return filepath.Dir(filepath.Dir(file))
+}
+
+func automationHTTPFreePort(t *testing.T) int {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("allocate port failed: %v", err)
+ }
+ defer ln.Close()
+ return ln.Addr().(*net.TCPAddr).Port
+}
+
+func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error {
+ repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
+ tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
+ if _, err := os.Stat(repoRuntimeDir); err != nil {
+ return fmt.Errorf("repo runtime not found: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil {
+ return err
+ }
+ if err := automationHTTPCopyFile(
+ filepath.Join(repoRuntimeDir, "runner.cjs"),
+ filepath.Join(tempRuntimeDir, "runner.cjs"),
+ ); err != nil {
+ return err
+ }
+ return automationHTTPCopyDir(
+ filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"),
+ filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"),
+ )
+}
+
+func automationHTTPCopyFile(src string, dst string) error {
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(dst, data, 0o644)
+}
+
+func automationHTTPCopyDir(src string, dst string) error {
+ return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+
+ relativePath, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ targetPath := filepath.Join(dst, relativePath)
+ if info.IsDir() {
+ return os.MkdirAll(targetPath, 0o755)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(targetPath, data, info.Mode())
+ })
+}
+
+func automationHTTPRequestJSON(method string, url string, payload any, target any) error {
+ var body io.Reader
+ if payload != nil {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(data)
+ }
+
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return err
+ }
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+ if target == nil {
+ return nil
+ }
+ if err := json.Unmarshal(raw, target); err != nil {
+ return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw))
+ }
+ return nil
+}
+
+func automationHTTPHasScript(items []struct {
+ ID string `json:"id"`
+}, scriptID string) bool {
+ for _, item := range items {
+ if strings.TrimSpace(item.ID) == scriptID {
+ return true
+ }
+ }
+ return false
+}
+
+func automationHTTPStringValue(payload map[string]any, key string) string {
+ if payload == nil {
+ return ""
+ }
+ value, _ := payload[key].(string)
+ return strings.TrimSpace(value)
+}
+
+func automationHTTPMarshal(t *testing.T, value any) string {
+ t.Helper()
+
+ data, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal debug payload failed: %v", err)
+ }
+ return string(data)
+}
+
+const automationHTTPMailFixtureHTML = `
+
+
+
+ Mail Fixture
+
+
+
+
+
+
+
+ Your ChatGPT verification code
+ From: ChatGPT <noreply@tm.openai.com>
+ To: target@example.com
+ Hello,
+ Your verification code is 429792.
+ Please use this code to continue signing in.
+ Best regards
+ ChatGPT
+
+
+
+`
+
+const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
+ const normalizeText = (value) => String(value == null ? '' : value).trim()
+ const timeoutMs = Number.isFinite(Number(params.timeoutMs))
+ ? Math.max(5000, Math.round(Number(params.timeoutMs)))
+ : 45000
+ const inboxUrl = normalizeText(params.inboxUrl)
+
+ if (!inboxUrl) {
+ throw new Error('inboxUrl is required')
+ }
+
+ const session = await launch({
+ selector,
+ skipDefaultStartUrls: true,
+ startUrls: [inboxUrl],
+ })
+ const connection = await connect(session, { timeoutMs })
+ const browser = connection.browser
+ if (!browser) {
+ throw new Error('browser connection is unavailable')
+ }
+
+ const context =
+ connection.context ||
+ browser.contexts()[0] ||
+ (typeof browser.newContext === 'function' ? await browser.newContext() : null)
+ if (!context) {
+ throw new Error('browser context is unavailable')
+ }
+
+ const opened = await openPage(connection, {
+ url: inboxUrl,
+ timeoutMs,
+ permissions: ['notifications'],
+ })
+ const page = opened.page
+ await page.waitForLoadState('networkidle', {
+ timeout: Math.min(timeoutMs, 2500),
+ }).catch(() => {})
+
+ const result = await page.evaluate(() => {
+ const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
+ const article = document.querySelector('article')
+ const lines = Array.from(document.querySelectorAll('article p'))
+ .map((node) => normalizeText(node.textContent))
+ .filter(Boolean)
+ const subject = normalizeText(document.querySelector('article h1')?.textContent)
+ const fromLine = lines.find((line) => line.startsWith('From:')) || ''
+ const toLine = lines.find((line) => line.startsWith('To:')) || ''
+ const articleText = normalizeText(article?.textContent)
+ const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*)
+ const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
+ const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
+ const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
+ const signature = lines.slice(-2).join('\n')
+
+ return {
+ notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
+ notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
+ mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
+ senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
+ recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
+ subject,
+ verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
+ signature,
+ }
+ })
+
+ return {
+ ok: true,
+ permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
+ permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
+ summary: '已提取测试邮件内容',
+ ...result,
+ }
+}`
+
+var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
+
+var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
+ automationHTTPMailProbeScriptTextRaw,
+ "summary: 'mail probe extracted message',",
+)
diff --git a/backend/automation_script_http_e2e_test.go b/backend/automation_script_http_e2e_test.go
index 022d7172..d10bac71 100644
--- a/backend/automation_script_http_e2e_test.go
+++ b/backend/automation_script_http_e2e_test.go
@@ -1,20 +1,14 @@
package backend
import (
- "bytes"
"encoding/json"
"fmt"
"io"
- "net"
"net/http"
"net/http/httptest"
- "os"
"path/filepath"
- "regexp"
- goruntime "runtime"
"strings"
"testing"
- "time"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/config"
@@ -262,327 +256,3 @@ func TestAutomationScriptRunHTTPReturnsSavedMailProbeScript(t *testing.T) {
"subject": automationHTTPStringValue(parsed, "subject"),
}))
}
-
-func lookupAutomationHTTPProbeNode(t *testing.T) string {
- t.Helper()
-
- const preferred = `D:\code\plugin\nodejs\node.exe`
- if _, err := os.Stat(preferred); err == nil {
- return preferred
- }
- return lookupAutomationTestNode(t)
-}
-
-func lookupAutomationHTTPProbeChrome(t *testing.T) string {
- t.Helper()
-
- const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe`
- if _, err := os.Stat(preferred); err == nil {
- return preferred
- }
- t.Skip("system chrome is not installed")
- return ""
-}
-
-func automationHTTPRepoRoot(t *testing.T) string {
- t.Helper()
-
- _, file, _, ok := goruntime.Caller(0)
- if !ok {
- t.Fatal("resolve repo root failed")
- }
- return filepath.Dir(filepath.Dir(file))
-}
-
-func automationHTTPFreePort(t *testing.T) int {
- t.Helper()
-
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("allocate port failed: %v", err)
- }
- defer ln.Close()
- return ln.Addr().(*net.TCPAddr).Port
-}
-
-func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error {
- repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
- tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
- if _, err := os.Stat(repoRuntimeDir); err != nil {
- return fmt.Errorf("repo runtime not found: %w", err)
- }
- if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil {
- return err
- }
- if err := automationHTTPCopyFile(
- filepath.Join(repoRuntimeDir, "runner.cjs"),
- filepath.Join(tempRuntimeDir, "runner.cjs"),
- ); err != nil {
- return err
- }
- return automationHTTPCopyDir(
- filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"),
- filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"),
- )
-}
-
-func automationHTTPCopyFile(src string, dst string) error {
- data, err := os.ReadFile(src)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
- return err
- }
- return os.WriteFile(dst, data, 0o644)
-}
-
-func automationHTTPCopyDir(src string, dst string) error {
- return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
-
- relativePath, err := filepath.Rel(src, path)
- if err != nil {
- return err
- }
- targetPath := filepath.Join(dst, relativePath)
- if info.IsDir() {
- return os.MkdirAll(targetPath, 0o755)
- }
-
- data, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
- return err
- }
- return os.WriteFile(targetPath, data, info.Mode())
- })
-}
-
-func automationHTTPRequestJSON(method string, url string, payload any, target any) error {
- var body io.Reader
- if payload != nil {
- data, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- body = bytes.NewReader(data)
- }
-
- req, err := http.NewRequest(method, url, body)
- if err != nil {
- return err
- }
- if payload != nil {
- req.Header.Set("Content-Type", "application/json")
- }
-
- resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- raw, err := io.ReadAll(resp.Body)
- if err != nil {
- return err
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw)))
- }
- if target == nil {
- return nil
- }
- if err := json.Unmarshal(raw, target); err != nil {
- return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw))
- }
- return nil
-}
-
-func automationHTTPHasScript(items []struct {
- ID string `json:"id"`
-}, scriptID string) bool {
- for _, item := range items {
- if strings.TrimSpace(item.ID) == scriptID {
- return true
- }
- }
- return false
-}
-
-func automationHTTPStringValue(payload map[string]any, key string) string {
- if payload == nil {
- return ""
- }
- value, _ := payload[key].(string)
- return strings.TrimSpace(value)
-}
-
-func automationHTTPMarshal(t *testing.T, value any) string {
- t.Helper()
-
- data, err := json.Marshal(value)
- if err != nil {
- t.Fatalf("marshal debug payload failed: %v", err)
- }
- return string(data)
-}
-
-const automationHTTPMailFixtureHTML = `
-
-
-
- Mail Fixture
-
-
-
-
-
-
-
- Your ChatGPT verification code
- From: ChatGPT <noreply@tm.openai.com>
- To: target@example.com
- Hello,
- Your verification code is 429792.
- Please use this code to continue signing in.
- Best regards
- ChatGPT
-
-
-
-`
-
-const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
- const normalizeText = (value) => String(value == null ? '' : value).trim()
- const timeoutMs = Number.isFinite(Number(params.timeoutMs))
- ? Math.max(5000, Math.round(Number(params.timeoutMs)))
- : 45000
- const inboxUrl = normalizeText(params.inboxUrl)
-
- if (!inboxUrl) {
- throw new Error('inboxUrl is required')
- }
-
- const session = await launch({
- selector,
- skipDefaultStartUrls: true,
- startUrls: [inboxUrl],
- })
- const connection = await connect(session, { timeoutMs })
- const browser = connection.browser
- if (!browser) {
- throw new Error('browser connection is unavailable')
- }
-
- const context =
- connection.context ||
- browser.contexts()[0] ||
- (typeof browser.newContext === 'function' ? await browser.newContext() : null)
- if (!context) {
- throw new Error('browser context is unavailable')
- }
-
- const opened = await openPage(connection, {
- url: inboxUrl,
- timeoutMs,
- permissions: ['notifications'],
- })
- const page = opened.page
- await page.waitForLoadState('networkidle', {
- timeout: Math.min(timeoutMs, 2500),
- }).catch(() => {})
-
- const result = await page.evaluate(() => {
- const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
- const article = document.querySelector('article')
- const lines = Array.from(document.querySelectorAll('article p'))
- .map((node) => normalizeText(node.textContent))
- .filter(Boolean)
- const subject = normalizeText(document.querySelector('article h1')?.textContent)
- const fromLine = lines.find((line) => line.startsWith('From:')) || ''
- const toLine = lines.find((line) => line.startsWith('To:')) || ''
- const articleText = normalizeText(article?.textContent)
- const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*)
- const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
- const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
- const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
- const signature = lines.slice(-2).join('\n')
-
- return {
- notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
- notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
- mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
- senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
- recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
- subject,
- verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
- signature,
- }
- })
-
- return {
- ok: true,
- permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
- permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
- summary: '已提取测试邮件内容',
- ...result,
- }
-}`
-
-var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
-
-var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
- automationHTTPMailProbeScriptTextRaw,
- "summary: 'mail probe extracted message',",
-)
diff --git a/backend/automation_script_import_test.go b/backend/automation_script_import_test.go
new file mode 100644
index 00000000..af8b3db7
--- /dev/null
+++ b/backend/automation_script_import_test.go
@@ -0,0 +1,195 @@
+package backend
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/automation"
+)
+
+func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) {
+ app := NewApp(t.TempDir())
+
+ sourceDir := filepath.Join(t.TempDir(), "local-dir-script")
+ if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil {
+ t.Fatalf("create local dir source failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{
+ "name": "本地目录脚本",
+ "type": "playwright-cdp",
+ "entryFile": "scripts/index.cjs"
+}`), 0o644); err != nil {
+ t.Fatalf("write local dir manifest failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil {
+ t.Fatalf("write local dir entry failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil {
+ t.Fatalf("write local dir helper failed: %v", err)
+ }
+
+ saved, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "refresh-local-dir",
+ Name: "旧本地目录脚本",
+ Type: "launch-api",
+ Status: "ready",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: false })",
+ Source: automation.ScriptSource{
+ Type: "local-dir",
+ URI: sourceDir,
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ refreshed, err := app.AutomationScriptRefresh(saved.ID)
+ if err != nil {
+ t.Fatalf("AutomationScriptRefresh returned error: %v", err)
+ }
+ if refreshed == nil {
+ t.Fatalf("AutomationScriptRefresh returned nil result")
+ }
+ if refreshed.ID != saved.ID {
+ t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
+ }
+ if refreshed.Status != "ready" {
+ t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
+ }
+ if refreshed.EntryFile != "scripts/index.cjs" {
+ t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile)
+ }
+ if !strings.Contains(refreshed.ScriptText, "helper.run()") {
+ t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText)
+ }
+}
+
+func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) {
+ app := NewApp(t.TempDir())
+ libraryRoot := filepath.Join(t.TempDir(), "script-library")
+
+ firstScriptDir := filepath.Join(libraryRoot, "first-script")
+ writeAutomationScriptLibraryPackage(t, firstScriptDir, `{
+ "name": "脚本一",
+ "type": "playwright-cdp",
+ "entryFile": "index.cjs"
+}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })")
+
+ secondScriptDir := filepath.Join(libraryRoot, "second-script")
+ if err := os.MkdirAll(secondScriptDir, 0o755); err != nil {
+ t.Fatalf("create second script dir failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil {
+ t.Fatalf("write second script entry failed: %v", err)
+ }
+
+ existing, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "existing-local-library-script",
+ Name: "旧脚本一",
+ Type: "launch-api",
+ Status: "disabled",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: false })",
+ Source: automation.ScriptSource{
+ Type: "local-dir",
+ URI: firstScriptDir,
+ },
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Path: "library/existing-script",
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ result, err := app.importAutomationLocalLibrary(libraryRoot)
+ if err != nil {
+ t.Fatalf("importAutomationLocalLibrary returned error: %v", err)
+ }
+ if result == nil {
+ t.Fatalf("importAutomationLocalLibrary returned nil result")
+ }
+ if result.Scanned != 2 {
+ t.Fatalf("expected scanned count 2, got %d", result.Scanned)
+ }
+ if len(result.Imported) != 2 {
+ t.Fatalf("expected two imported scripts, got %d", len(result.Imported))
+ }
+ if len(result.Failed) != 0 {
+ t.Fatalf("expected no failed imports, got %+v", result.Failed)
+ }
+
+ updatedFirst, err := app.AutomationScriptGet(existing.ID)
+ if err != nil {
+ t.Fatalf("AutomationScriptGet returned error: %v", err)
+ }
+ if updatedFirst.Name != "脚本一" {
+ t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name)
+ }
+ if updatedFirst.Status != "disabled" {
+ t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status)
+ }
+ if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir {
+ t.Fatalf("unexpected updated source: %+v", updatedFirst.Source)
+ }
+ if !strings.Contains(updatedFirst.ScriptText, "first-script") {
+ t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText)
+ }
+ if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled {
+ t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI)
+ }
+
+ allScripts, err := app.automationScriptStore().List()
+ if err != nil {
+ t.Fatalf("List returned error: %v", err)
+ }
+ if len(allScripts) != 2 {
+ t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts))
+ }
+}
+
+func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) {
+ app := NewApp(t.TempDir())
+ libraryRoot := filepath.Join(t.TempDir(), "script-library")
+
+ goodDir := filepath.Join(libraryRoot, "good-script")
+ writeAutomationScriptLibraryPackage(t, goodDir, `{
+ "name": "好脚本",
+ "type": "playwright-cdp",
+ "entryFile": "index.cjs"
+}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })")
+
+ badDir := filepath.Join(libraryRoot, "bad-script")
+ writeAutomationScriptLibraryPackage(t, badDir, `{
+ "name": "坏脚本",
+ "type": "playwright-cdp",
+ "entryFile": "missing.cjs"
+}`, "")
+
+ result, err := app.importAutomationLocalLibrary(libraryRoot)
+ if err != nil {
+ t.Fatalf("importAutomationLocalLibrary returned error: %v", err)
+ }
+ if result == nil {
+ t.Fatalf("importAutomationLocalLibrary returned nil result")
+ }
+ if result.Scanned != 2 {
+ t.Fatalf("expected scanned count 2, got %d", result.Scanned)
+ }
+ if len(result.Imported) != 1 {
+ t.Fatalf("expected one imported script, got %d", len(result.Imported))
+ }
+ if len(result.Failed) != 1 {
+ t.Fatalf("expected one failed script, got %+v", result.Failed)
+ }
+ if result.Failed[0].Path != badDir {
+ t.Fatalf("unexpected failed path: %+v", result.Failed[0])
+ }
+ if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") {
+ t.Fatalf("unexpected failed message: %+v", result.Failed[0])
+ }
+}
diff --git a/backend/automation_script_remote_test.go b/backend/automation_script_remote_test.go
new file mode 100644
index 00000000..d199c602
--- /dev/null
+++ b/backend/automation_script_remote_test.go
@@ -0,0 +1,321 @@
+package backend
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/automation"
+ "ant-chrome/backend/internal/config"
+)
+
+func TestAutomationScriptRefreshFromRemote(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{
+ "manifest": {
+ "name": "远程刷新脚本",
+ "description": "来自远程",
+ "type": "playwright-cdp",
+ "entryFile": "index.cjs"
+ },
+ "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })"
+}`))
+ }))
+ defer server.Close()
+
+ app := NewApp(t.TempDir())
+ saved, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "refresh-remote",
+ Name: "旧远程脚本",
+ Type: "launch-api",
+ Status: "ready",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: false })",
+ Source: automation.ScriptSource{
+ Type: "remote-url",
+ URI: server.URL + "/script.json",
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ refreshed, err := app.AutomationScriptRefresh(saved.ID)
+ if err != nil {
+ t.Fatalf("AutomationScriptRefresh returned error: %v", err)
+ }
+ if refreshed == nil {
+ t.Fatalf("AutomationScriptRefresh returned nil result")
+ }
+ if refreshed.ID != saved.ID {
+ t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
+ }
+ if refreshed.Name != "远程刷新脚本" {
+ t.Fatalf("expected remote manifest name, got %q", refreshed.Name)
+ }
+ if refreshed.Status != "ready" {
+ t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
+ }
+ if !strings.Contains(refreshed.ScriptText, "source: 'remote'") {
+ t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText)
+ }
+ if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" {
+ t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
+ }
+}
+
+func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) {
+ app := NewApp(t.TempDir())
+
+ zipData := buildAutomationZipBytesForTest(t, map[string]string{
+ "automation.script.json": `{
+ "name": "远程 ZIP",
+ "type": "playwright-cdp",
+ "entryFile": "scripts/index.cjs"
+}`,
+ "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })",
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/zip")
+ _, _ = w.Write(zipData)
+ }))
+ defer server.Close()
+
+ bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip")
+ if err != nil {
+ t.Fatalf("loadAutomationRemoteBundle returned error: %v", err)
+ }
+
+ if bundle.Record.Name != "远程 ZIP" {
+ t.Fatalf("unexpected bundle name: %s", bundle.Record.Name)
+ }
+ if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" {
+ t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
+ }
+ if !strings.Contains(bundle.Record.ScriptText, "remote-zip") {
+ t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText)
+ }
+}
+
+func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) {
+ app := NewApp(t.TempDir())
+ app.config = config.DefaultConfig()
+ app.config.Automation.AllowTypeScriptBuild = true
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(`export async function run() {
+ return { ok: true, source: 'remote-ts' }
+}`))
+ }))
+ defer server.Close()
+
+ bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts")
+ if err != nil {
+ t.Fatalf("loadAutomationRemoteBundle returned error: %v", err)
+ }
+
+ if bundle.Record.EntryFile != "demo-script.cjs" {
+ t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile)
+ }
+ if !strings.Contains(bundle.Record.ScriptText, "remote-ts") {
+ t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText)
+ }
+ if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" {
+ t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
+ }
+}
+
+func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`export async function run() {
+ return { ok: true, source: 'remote-ts-refresh' }
+}`))
+ }))
+ defer server.Close()
+
+ app := NewApp(t.TempDir())
+ app.config = config.DefaultConfig()
+ app.config.Automation.AllowTypeScriptBuild = true
+
+ saved, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "refresh-remote-ts",
+ Name: "旧远程 TS 脚本",
+ Type: "launch-api",
+ Status: "ready",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: false })",
+ Source: automation.ScriptSource{
+ Type: "remote-url",
+ URI: server.URL + "/refresh-script.ts",
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ refreshed, err := app.AutomationScriptRefresh(saved.ID)
+ if err != nil {
+ t.Fatalf("AutomationScriptRefresh returned error: %v", err)
+ }
+ if refreshed.EntryFile != "refresh-script.cjs" {
+ t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile)
+ }
+ if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") {
+ t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText)
+ }
+ if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" {
+ t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
+ }
+}
+
+func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git is not installed")
+ }
+
+ repoDir := filepath.Join(t.TempDir(), "automation-ts-repo")
+ if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil {
+ t.Fatalf("create repo dir failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{
+ "name": "Git TS 导入",
+ "type": "playwright-cdp",
+ "entryFile": "index.ts"
+}`), 0o644); err != nil {
+ t.Fatalf("write git manifest failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag'
+
+export async function run() {
+ return { ok: flag, source: 'git-ts' }
+}`), 0o644); err != nil {
+ t.Fatalf("write git entry file failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil {
+ t.Fatalf("write git helper file failed: %v", err)
+ }
+
+ runGitForTest(t, repoDir, "init")
+ runGitForTest(t, repoDir, "config", "user.email", "test@example.com")
+ runGitForTest(t, repoDir, "config", "user.name", "Test User")
+ runGitForTest(t, repoDir, "add", ".")
+ runGitForTest(t, repoDir, "commit", "-m", "init")
+
+ app := NewApp(t.TempDir())
+ app.config = config.DefaultConfig()
+ app.config.Automation.AllowTypeScriptBuild = true
+
+ bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo")
+ if err != nil {
+ t.Fatalf("loadAutomationGitBundle returned error: %v", err)
+ }
+
+ if bundle.Record.Name != "Git TS 导入" {
+ t.Fatalf("unexpected bundle name: %s", bundle.Record.Name)
+ }
+ if bundle.Record.EntryFile != "index.cjs" {
+ t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile)
+ }
+ if !strings.Contains(bundle.Record.ScriptText, "git-ts") {
+ t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText)
+ }
+ if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" {
+ t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source)
+ }
+}
+
+func TestAutomationScriptRefreshFromGit(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git is not installed")
+ }
+
+ repoDir := filepath.Join(t.TempDir(), "automation-repo")
+ if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil {
+ t.Fatalf("create repo dir failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{
+ "name": "Git 刷新脚本",
+ "type": "playwright-cdp",
+ "entryFile": "index.cjs"
+}`), 0o644); err != nil {
+ t.Fatalf("write git manifest failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil {
+ t.Fatalf("write git entry file failed: %v", err)
+ }
+
+ runGitForTest(t, repoDir, "init")
+ runGitForTest(t, repoDir, "config", "user.email", "test@example.com")
+ runGitForTest(t, repoDir, "config", "user.name", "Test User")
+ runGitForTest(t, repoDir, "add", ".")
+ runGitForTest(t, repoDir, "commit", "-m", "init")
+
+ app := NewApp(t.TempDir())
+ saved, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "refresh-git",
+ Name: "旧 Git 脚本",
+ Type: "launch-api",
+ Status: "ready",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: false })",
+ Source: automation.ScriptSource{
+ Type: "git",
+ URI: repoDir,
+ Path: "scripts/demo",
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ refreshed, err := app.AutomationScriptRefresh(saved.ID)
+ if err != nil {
+ t.Fatalf("AutomationScriptRefresh returned error: %v", err)
+ }
+ if refreshed == nil {
+ t.Fatalf("AutomationScriptRefresh returned nil result")
+ }
+ if refreshed.ID != saved.ID {
+ t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID)
+ }
+ if refreshed.Name != "Git 刷新脚本" {
+ t.Fatalf("expected git manifest name, got %q", refreshed.Name)
+ }
+ if refreshed.Status != "ready" {
+ t.Fatalf("expected status to be preserved, got %q", refreshed.Status)
+ }
+ if !strings.Contains(refreshed.ScriptText, "source: 'git'") {
+ t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText)
+ }
+ if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" {
+ t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
+ }
+}
+
+func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) {
+ app := NewApp(t.TempDir())
+ saved, err := app.AutomationScriptSave(automation.ScriptRecord{
+ ID: "refresh-manual",
+ Name: "手动脚本",
+ Type: "playwright-cdp",
+ Status: "ready",
+ EntryFile: "index.cjs",
+ ScriptText: "module.exports.run = async () => ({ ok: true })",
+ Source: automation.ScriptSource{
+ Type: "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("AutomationScriptSave returned error: %v", err)
+ }
+
+ if _, err := app.AutomationScriptRefresh(saved.ID); err == nil {
+ t.Fatalf("expected unsupported source refresh to fail")
+ }
+}
diff --git a/backend/automation_script_run_integration_helpers_test.go b/backend/automation_script_run_integration_helpers_test.go
new file mode 100644
index 00000000..66cc55da
--- /dev/null
+++ b/backend/automation_script_run_integration_helpers_test.go
@@ -0,0 +1,249 @@
+package backend
+
+import (
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "ant-chrome/backend/internal/automation"
+ "ant-chrome/backend/internal/browser"
+ "ant-chrome/backend/internal/config"
+ "ant-chrome/backend/internal/launchcode"
+)
+
+func lookupAutomationTestNode(t *testing.T) string {
+ t.Helper()
+
+ nodeExecPath, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node is not installed")
+ }
+ return nodeExecPath
+}
+
+func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) {
+ t.Helper()
+
+ prepareAutomationTestRuntimeWithPlaywrightModule(
+ t,
+ manager,
+ playwrightVersion,
+ "module.exports = { chromium: {} }\n",
+ )
+}
+
+func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) {
+ t.Helper()
+
+ state := manager.CurrentState()
+
+ playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core")
+ if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil {
+ t.Fatalf("create playwright-core dir failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil {
+ t.Fatalf("write playwright-core package failed: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil {
+ t.Fatalf("write playwright-core stub failed: %v", err)
+ }
+ if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+}
+
+const automationTestConnectProbePlaywrightModule = `const http = require('http')
+
+module.exports = {
+ chromium: {
+ connectOverCDP: async (endpoint) => {
+ const target = new URL('/json/version', endpoint)
+ await new Promise((resolve, reject) => {
+ const req = http.get(target, (res) => {
+ res.resume()
+ res.on('end', () => {
+ const status = res.statusCode || 0
+ if (status >= 200 && status < 300) {
+ resolve()
+ return
+ }
+ reject(new Error('cdp connect probe failed with http ' + String(status)))
+ })
+ })
+ req.on('error', reject)
+ })
+
+ return {
+ contexts: () => [{
+ pages: () => [],
+ newPage: async () => ({})
+ }],
+ close: async () => {}
+ }
+ }
+ }
+}
+`
+
+const automationTestRunnerScript = `const fs = require('fs')
+const path = require('path')
+
+async function main() {
+ const payloadPath = process.argv[2]
+ const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8'))
+ const script = require(payload.ScriptPath)
+ const startedAt = new Date().toISOString()
+ const result = await script.run({
+ selector: payload.Selector || {},
+ params: payload.Params || {},
+ artifact: (name) => {
+ const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath)
+ fs.mkdirSync(dir, { recursive: true })
+ return path.join(dir, name)
+ },
+ log: () => {},
+ launch: async () => ({ ok: true }),
+ connect: async () => ({
+ browser: { contexts: () => [] },
+ context: {
+ pages: () => [],
+ newPage: async () => ({})
+ },
+ page: null
+ })
+ })
+
+ console.log(JSON.stringify({
+ ok: result && result.ok !== false,
+ summary: result && result.summary ? String(result.summary) : '',
+ error: result && result.error ? String(result.error) : '',
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ ...result
+ }))
+}
+
+main().catch((error) => {
+ console.error(error && error.stack ? error.stack : String(error))
+ process.exit(1)
+})
+`
+
+type automationConcurrentRunResult struct {
+ run *automation.ScriptRunRecord
+ err error
+}
+
+func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) {
+ t.Helper()
+
+ nodeExecPath := lookupAutomationTestNode(t)
+
+ app := NewApp(t.TempDir())
+ app.config = config.DefaultConfig()
+ app.config.Automation.Enabled = true
+ app.config.Automation.NodeSource = config.AutomationNodeSourceSystem
+ app.config.Automation.SystemNodePath = nodeExecPath
+ app.config.Automation.NodeVersion = "test-node"
+ app.config.Automation.PlaywrightCoreVersion = "1.59.0"
+ app.config.Automation.RuntimeVersion = "test-runtime"
+ app.browserMgr = browser.NewManager(app.config, app.appRoot)
+ app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
+ app.browserMgr.CodeProvider = app.launchCodeSvc
+ app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{})
+
+ prepareAutomationTestRuntimeWithPlaywrightModule(
+ t,
+ app.automationMgr,
+ app.config.Automation.PlaywrightCoreVersion,
+ playwrightModuleSource,
+ )
+
+ app.launchServer = launchcode.NewLaunchServer(
+ app.launchCodeSvc,
+ app,
+ app.browserMgr,
+ 0,
+ )
+ if err := app.launchServer.Start(); err != nil {
+ t.Fatalf("start launch server failed: %v", err)
+ }
+
+ return app, func() {
+ _ = app.launchServer.Stop()
+ }
+}
+
+func automationTestServerPort(t *testing.T, rawURL string) int {
+ t.Helper()
+
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ t.Fatalf("parse server url failed: %v", err)
+ }
+ port, err := strconv.Atoi(parsed.Port())
+ if err != nil {
+ t.Fatalf("parse server port failed: %v", err)
+ }
+ return port
+}
+
+func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile {
+ t.Helper()
+
+ profile, err := app.browserMgr.Create(browser.ProfileInput{
+ ProfileName: name,
+ })
+ if err != nil {
+ t.Fatalf("create profile failed: %v", err)
+ }
+ if profile == nil {
+ t.Fatal("create profile returned nil")
+ }
+ if strings.TrimSpace(code) != "" {
+ if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil {
+ t.Fatalf("set code failed: %v", err)
+ }
+ }
+
+ app.browserMgr.Profiles[profile.ProfileId].Running = true
+ app.browserMgr.Profiles[profile.ProfileId].DebugReady = true
+ app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort
+ app.browserMgr.Profiles[profile.ProfileId].Pid = 12345
+
+ return profile
+}
+
+func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult {
+ t.Helper()
+
+ results := make([]automationConcurrentRunResult, count)
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+
+ for index := 0; index < count; index++ {
+ index := index
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ run, err := runner(index)
+ results[index] = automationConcurrentRunResult{
+ run: run,
+ err: err,
+ }
+ }()
+ }
+
+ time.Sleep(50 * time.Millisecond)
+ close(start)
+ wg.Wait()
+
+ return results
+}
diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go
index 34323673..ece7cc8c 100644
--- a/backend/automation_script_run_integration_test.go
+++ b/backend/automation_script_run_integration_test.go
@@ -7,14 +7,10 @@ import (
"net/http/httptest"
"net/url"
"os"
- "os/exec"
- "path/filepath"
"strconv"
"strings"
- "sync"
"sync/atomic"
"testing"
- "time"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/browser"
@@ -436,234 +432,3 @@ func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *te
t.Fatalf("expected one success and one failure on same profile, got success=%d failed=%d results=%+v", successCount, failedCount, results)
}
}
-
-func lookupAutomationTestNode(t *testing.T) string {
- t.Helper()
-
- nodeExecPath, err := exec.LookPath("node")
- if err != nil {
- t.Skip("node is not installed")
- }
- return nodeExecPath
-}
-
-func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) {
- t.Helper()
-
- prepareAutomationTestRuntimeWithPlaywrightModule(
- t,
- manager,
- playwrightVersion,
- "module.exports = { chromium: {} }\n",
- )
-}
-
-func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) {
- t.Helper()
-
- state := manager.CurrentState()
-
- playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core")
- if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil {
- t.Fatalf("create playwright-core dir failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil {
- t.Fatalf("write playwright-core package failed: %v", err)
- }
- if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil {
- t.Fatalf("write playwright-core stub failed: %v", err)
- }
- if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
-}
-
-const automationTestConnectProbePlaywrightModule = `const http = require('http')
-
-module.exports = {
- chromium: {
- connectOverCDP: async (endpoint) => {
- const target = new URL('/json/version', endpoint)
- await new Promise((resolve, reject) => {
- const req = http.get(target, (res) => {
- res.resume()
- res.on('end', () => {
- const status = res.statusCode || 0
- if (status >= 200 && status < 300) {
- resolve()
- return
- }
- reject(new Error('cdp connect probe failed with http ' + String(status)))
- })
- })
- req.on('error', reject)
- })
-
- return {
- contexts: () => [{
- pages: () => [],
- newPage: async () => ({})
- }],
- close: async () => {}
- }
- }
- }
-}
-`
-
-const automationTestRunnerScript = `const fs = require('fs')
-const path = require('path')
-
-async function main() {
- const payloadPath = process.argv[2]
- const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8'))
- const script = require(payload.ScriptPath)
- const startedAt = new Date().toISOString()
- const result = await script.run({
- selector: payload.Selector || {},
- params: payload.Params || {},
- artifact: (name) => {
- const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath)
- fs.mkdirSync(dir, { recursive: true })
- return path.join(dir, name)
- },
- log: () => {},
- launch: async () => ({ ok: true }),
- connect: async () => ({
- browser: { contexts: () => [] },
- context: {
- pages: () => [],
- newPage: async () => ({})
- },
- page: null
- })
- })
-
- console.log(JSON.stringify({
- ok: result && result.ok !== false,
- summary: result && result.summary ? String(result.summary) : '',
- error: result && result.error ? String(result.error) : '',
- startedAt,
- finishedAt: new Date().toISOString(),
- ...result
- }))
-}
-
-main().catch((error) => {
- console.error(error && error.stack ? error.stack : String(error))
- process.exit(1)
-})
-`
-
-type automationConcurrentRunResult struct {
- run *automation.ScriptRunRecord
- err error
-}
-
-func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) {
- t.Helper()
-
- nodeExecPath := lookupAutomationTestNode(t)
-
- app := NewApp(t.TempDir())
- app.config = config.DefaultConfig()
- app.config.Automation.Enabled = true
- app.config.Automation.NodeSource = config.AutomationNodeSourceSystem
- app.config.Automation.SystemNodePath = nodeExecPath
- app.config.Automation.NodeVersion = "test-node"
- app.config.Automation.PlaywrightCoreVersion = "1.59.0"
- app.config.Automation.RuntimeVersion = "test-runtime"
- app.browserMgr = browser.NewManager(app.config, app.appRoot)
- app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
- app.browserMgr.CodeProvider = app.launchCodeSvc
- app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{})
-
- prepareAutomationTestRuntimeWithPlaywrightModule(
- t,
- app.automationMgr,
- app.config.Automation.PlaywrightCoreVersion,
- playwrightModuleSource,
- )
-
- app.launchServer = launchcode.NewLaunchServer(
- app.launchCodeSvc,
- app,
- app.browserMgr,
- 0,
- )
- if err := app.launchServer.Start(); err != nil {
- t.Fatalf("start launch server failed: %v", err)
- }
-
- return app, func() {
- _ = app.launchServer.Stop()
- }
-}
-
-func automationTestServerPort(t *testing.T, rawURL string) int {
- t.Helper()
-
- parsed, err := url.Parse(rawURL)
- if err != nil {
- t.Fatalf("parse server url failed: %v", err)
- }
- port, err := strconv.Atoi(parsed.Port())
- if err != nil {
- t.Fatalf("parse server port failed: %v", err)
- }
- return port
-}
-
-func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile {
- t.Helper()
-
- profile, err := app.browserMgr.Create(browser.ProfileInput{
- ProfileName: name,
- })
- if err != nil {
- t.Fatalf("create profile failed: %v", err)
- }
- if profile == nil {
- t.Fatal("create profile returned nil")
- }
- if strings.TrimSpace(code) != "" {
- if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil {
- t.Fatalf("set code failed: %v", err)
- }
- }
-
- app.browserMgr.Profiles[profile.ProfileId].Running = true
- app.browserMgr.Profiles[profile.ProfileId].DebugReady = true
- app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort
- app.browserMgr.Profiles[profile.ProfileId].Pid = 12345
-
- return profile
-}
-
-func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult {
- t.Helper()
-
- results := make([]automationConcurrentRunResult, count)
- start := make(chan struct{})
- var wg sync.WaitGroup
-
- for index := 0; index < count; index++ {
- index := index
- wg.Add(1)
- go func() {
- defer wg.Done()
- <-start
- run, err := runner(index)
- results[index] = automationConcurrentRunResult{
- run: run,
- err: err,
- }
- }()
- }
-
- time.Sleep(50 * time.Millisecond)
- close(start)
- wg.Wait()
-
- return results
-}
diff --git a/backend/automation_script_target_helpers.go b/backend/automation_script_target_helpers.go
new file mode 100644
index 00000000..4dd5183d
--- /dev/null
+++ b/backend/automation_script_target_helpers.go
@@ -0,0 +1,22 @@
+package backend
+
+import "strings"
+
+func appendAutomationRunSummary(summary string, targetSummary string) string {
+ summary = strings.TrimSpace(summary)
+ targetSummary = strings.TrimSpace(targetSummary)
+ if targetSummary == "" {
+ return summary
+ }
+ if summary == "" {
+ return targetSummary
+ }
+ return summary + " · " + targetSummary
+}
+
+func minAutomationInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/backend/automation_script_target_profile_helpers.go b/backend/automation_script_target_profile_helpers.go
new file mode 100644
index 00000000..70447e5b
--- /dev/null
+++ b/backend/automation_script_target_profile_helpers.go
@@ -0,0 +1,101 @@
+package backend
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "ant-chrome/backend/internal/browser"
+)
+
+func filterAutomationProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile {
+ filtered := make([]browser.Profile, 0, len(items))
+ for _, item := range items {
+ if keep(item) {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
+
+func automationProfileHasAllTags(profile browser.Profile, required []string) bool {
+ if len(required) == 0 {
+ return true
+ }
+ if len(profile.Tags) == 0 {
+ return false
+ }
+
+ for _, want := range required {
+ found := false
+ for _, tag := range profile.Tags {
+ if strings.EqualFold(strings.TrimSpace(tag), want) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
+
+func automationProfileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool {
+ if len(queries) == 0 {
+ return true
+ }
+ if len(profile.Keywords) == 0 {
+ return false
+ }
+
+ for _, query := range queries {
+ queryLower := strings.ToLower(strings.TrimSpace(query))
+ found := false
+ for _, keyword := range profile.Keywords {
+ if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
+
+func sortAutomationProfilesForTarget(items []browser.Profile) {
+ sort.Slice(items, func(i, j int) bool {
+ leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName))
+ rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName))
+ if leftName != rightName {
+ return leftName < rightName
+ }
+ return items[i].ProfileId < items[j].ProfileId
+ })
+}
+
+func buildAutomationTargetAmbiguousError(items []browser.Profile) string {
+ const maxPreview = 5
+ parts := make([]string, 0, minAutomationInt(len(items), maxPreview))
+ for i := 0; i < len(items) && i < maxPreview; i++ {
+ parts = append(parts, automationProfileLabel(items[i]))
+ }
+ suffix := ""
+ if len(items) > maxPreview {
+ suffix = fmt.Sprintf(" 等 %d 个实例", len(items))
+ }
+ return fmt.Sprintf("命中了多个实例:%s%s。请改用 code/profileId,或继续加分组、标签、关键字缩小范围", strings.Join(parts, ","), suffix)
+}
+
+func automationProfileLabel(profile browser.Profile) string {
+ label := strings.TrimSpace(profile.ProfileName)
+ if label == "" {
+ label = strings.TrimSpace(profile.ProfileId)
+ }
+ if code := strings.TrimSpace(profile.LaunchCode); code != "" {
+ return fmt.Sprintf("%s[id=%s, code=%s]", label, profile.ProfileId, code)
+ }
+ return fmt.Sprintf("%s[id=%s]", label, profile.ProfileId)
+}
diff --git a/backend/automation_script_target_resolver.go b/backend/automation_script_target_resolver.go
index b28502c9..510c456c 100644
--- a/backend/automation_script_target_resolver.go
+++ b/backend/automation_script_target_resolver.go
@@ -3,7 +3,6 @@ package backend
import (
"encoding/json"
"fmt"
- "sort"
"strings"
"time"
@@ -14,6 +13,9 @@ import (
const defaultAutomationCreateNameTemplate = "${templateName}-${timestamp}"
func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, input automation.ScriptRunRequest, required bool) (map[string]any, string, error) {
+ if mode := automationScriptRunTargetMode(script, input); mode != automationScriptTargetMode(script) {
+ script.TargetConfig.Mode = mode
+ }
overrideSelectorText := strings.TrimSpace(input.SelectorText)
if automationScriptTargetMode(script) == "manual" && !input.UseScriptSelector && overrideSelectorText != "" {
selector, err := parseAutomationJSONObject(overrideSelectorText, required)
@@ -52,6 +54,16 @@ func automationScriptTargetMode(script automation.ScriptRecord) string {
}
}
+func automationScriptRunTargetMode(script automation.ScriptRecord, input automation.ScriptRunRequest) string {
+ mode := strings.ToLower(strings.TrimSpace(input.TargetMode))
+ switch mode {
+ case "manual", "existing", "create", "rotate":
+ return mode
+ default:
+ return automationScriptTargetMode(script)
+ }
+}
+
func applyAutomationRunTargetInput(script automation.ScriptRecord, value any) (automation.ScriptRecord, error) {
if value == nil {
return script, nil
@@ -320,98 +332,6 @@ func automationTargetSelectorEmpty(selector automation.ScriptTargetSelector) boo
len(selector.Tags) == 0
}
-func filterAutomationProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile {
- filtered := make([]browser.Profile, 0, len(items))
- for _, item := range items {
- if keep(item) {
- filtered = append(filtered, item)
- }
- }
- return filtered
-}
-
-func automationProfileHasAllTags(profile browser.Profile, required []string) bool {
- if len(required) == 0 {
- return true
- }
- if len(profile.Tags) == 0 {
- return false
- }
-
- for _, want := range required {
- found := false
- for _, tag := range profile.Tags {
- if strings.EqualFold(strings.TrimSpace(tag), want) {
- found = true
- break
- }
- }
- if !found {
- return false
- }
- }
- return true
-}
-
-func automationProfileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool {
- if len(queries) == 0 {
- return true
- }
- if len(profile.Keywords) == 0 {
- return false
- }
-
- for _, query := range queries {
- queryLower := strings.ToLower(strings.TrimSpace(query))
- found := false
- for _, keyword := range profile.Keywords {
- if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) {
- found = true
- break
- }
- }
- if !found {
- return false
- }
- }
- return true
-}
-
-func sortAutomationProfilesForTarget(items []browser.Profile) {
- sort.Slice(items, func(i, j int) bool {
- leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName))
- rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName))
- if leftName != rightName {
- return leftName < rightName
- }
- return items[i].ProfileId < items[j].ProfileId
- })
-}
-
-func buildAutomationTargetAmbiguousError(items []browser.Profile) string {
- const maxPreview = 5
- parts := make([]string, 0, minAutomationInt(len(items), maxPreview))
- for i := 0; i < len(items) && i < maxPreview; i++ {
- parts = append(parts, automationProfileLabel(items[i]))
- }
- suffix := ""
- if len(items) > maxPreview {
- suffix = fmt.Sprintf(" 等 %d 个实例", len(items))
- }
- return fmt.Sprintf("命中了多个实例:%s%s。请改用 code/profileId,或继续加分组、标签、关键字缩小范围", strings.Join(parts, ","), suffix)
-}
-
-func automationProfileLabel(profile browser.Profile) string {
- label := strings.TrimSpace(profile.ProfileName)
- if label == "" {
- label = strings.TrimSpace(profile.ProfileId)
- }
- if code := strings.TrimSpace(profile.LaunchCode); code != "" {
- return fmt.Sprintf("%s[id=%s, code=%s]", label, profile.ProfileId, code)
- }
- return fmt.Sprintf("%s[id=%s]", label, profile.ProfileId)
-}
-
func automationProfileSelector(profileID string) map[string]any {
return map[string]any{
"profileId": strings.TrimSpace(profileID),
@@ -477,22 +397,3 @@ func buildAutomationCreatedProfileName(template string, script automation.Script
}
return fmt.Sprintf("%s-%s", templateName, now.Format("20060102-150405"))
}
-
-func appendAutomationRunSummary(summary string, targetSummary string) string {
- summary = strings.TrimSpace(summary)
- targetSummary = strings.TrimSpace(targetSummary)
- if targetSummary == "" {
- return summary
- }
- if summary == "" {
- return targetSummary
- }
- return summary + " · " + targetSummary
-}
-
-func minAutomationInt(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs
index dbc0bc4b..835843db 100644
--- a/backend/internal/automation/assets/runner.cjs
+++ b/backend/internal/automation/assets/runner.cjs
@@ -1,495 +1,20 @@
-const fs = require('fs');
-const http = require('http');
-const https = require('https');
+const fs = require('fs');
const path = require('path');
-const util = require('util');
-const { pathToFileURL } = require('url');
+const {
+ normalizeTimeout,
+ sleep,
+ writeStream,
+ closeBrowserConnection,
+ buildConnectEndpoints,
+ normalizePathUnderRoot,
+ requestJSON,
+ toSerializable,
+} = require('./runner_shared.cjs');
+const { normalizeOrigin, normalizePermissionList, normalizePageAPIRequest, executePageAPIRequest } = require('./runner_page_api.cjs');
+const { loadScriptModule } = require('./runner_script_loader.cjs');
const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']);
-function normalizeTimeout(value, fallback) {
- const parsed = Number(value);
- if (Number.isFinite(parsed) && parsed > 0) {
- return Math.round(parsed);
- }
- return fallback;
-}
-
-function isPlainObject(value) {
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
-}
-
-function hasOwnProperty(value, key) {
- return Object.prototype.hasOwnProperty.call(value, key);
-}
-
-function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-function writeStream(stream, text) {
- return new Promise((resolve, reject) => {
- stream.write(text, (error) => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- });
- });
-}
-
-async function closeBrowserConnection(browser) {
- if (!browser || typeof browser.close !== 'function') {
- return;
- }
- await browser.close({ reason: 'automation task finished' }).catch(() => {});
-}
-
-function normalizeEndpointCandidate(value) {
- const normalized = String(value || '').trim();
- if (!normalized) {
- return '';
- }
-
- try {
- const parsed = new URL(normalized);
- if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) {
- return '';
- }
- if (parsed.port === '0') {
- return '';
- }
- if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (!parsed.pathname || parsed.pathname === '/') && !parsed.search && !parsed.hash) {
- return parsed.origin;
- }
- return parsed.toString();
- } catch {
- return '';
- }
-}
-
-function buildConnectEndpoints(payload, session) {
- const candidates = [];
- const seen = new Set();
-
- const pushCandidate = (value) => {
- const endpoint = normalizeEndpointCandidate(value);
- if (!endpoint || seen.has(endpoint)) {
- return;
- }
- seen.add(endpoint);
- candidates.push(endpoint);
- };
-
- pushCandidate(session && session.cdpUrl);
-
- const debugPort = Number(session && session.debugPort);
- if (Number.isFinite(debugPort) && debugPort > 0) {
- pushCandidate(`http://127.0.0.1:${Math.round(debugPort)}`);
- }
-
- pushCandidate(payload && payload.launchBaseUrl);
- return candidates;
-}
-
-function normalizePathUnderRoot(rootDir, targetName) {
- const normalizedName = String(targetName || '').trim();
- const resolvedRoot = path.resolve(String(rootDir || ''));
- if (!resolvedRoot) {
- throw new Error('artifactDir is required');
- }
-
- const candidate = normalizedName ? path.resolve(resolvedRoot, normalizedName) : resolvedRoot;
- if (candidate !== resolvedRoot && !candidate.startsWith(`${resolvedRoot}${path.sep}`)) {
- throw new Error('artifact path escapes root directory');
- }
- return candidate;
-}
-
-async function requestJSON(method, requestURL, body, headers = {}) {
- const target = new URL(requestURL);
- const transport = target.protocol === 'https:' ? https : http;
- const payload = body == null ? '' : JSON.stringify(body);
-
- return await new Promise((resolve, reject) => {
- const req = transport.request(
- {
- protocol: target.protocol,
- hostname: target.hostname,
- port: target.port,
- path: `${target.pathname}${target.search}`,
- method,
- headers: {
- Accept: 'application/json',
- ...(payload
- ? {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(payload),
- }
- : {}),
- ...headers,
- },
- },
- (res) => {
- const chunks = [];
- res.on('data', (chunk) => chunks.push(chunk));
- res.on('end', () => {
- const rawText = Buffer.concat(chunks).toString('utf8').trim();
- let responseBody = {};
- if (rawText) {
- try {
- responseBody = JSON.parse(rawText);
- } catch {
- responseBody = { rawBody: rawText };
- }
- }
- resolve({
- status: res.statusCode || 0,
- body: responseBody,
- });
- });
- }
- );
-
- req.on('error', reject);
- if (payload) {
- req.write(payload);
- }
- req.end();
- });
-}
-
-function inspectValue(value) {
- return util.inspect(value, {
- depth: 4,
- breakLength: 120,
- maxArrayLength: 20,
- compact: false,
- });
-}
-
-function toSerializable(value, seen = new WeakSet()) {
- if (value == null) {
- return value;
- }
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
- return value;
- }
- if (typeof value === 'bigint') {
- return value.toString();
- }
- if (value instanceof Date) {
- return value.toISOString();
- }
- if (value instanceof Error) {
- return {
- name: value.name,
- message: value.message,
- stack: value.stack,
- };
- }
- if (Buffer.isBuffer(value)) {
- return value.toString('utf8');
- }
- if (Array.isArray(value)) {
- return value.map((item) => toSerializable(item, seen));
- }
- if (typeof value === 'function') {
- return `[Function ${value.name || 'anonymous'}]`;
- }
- if (typeof value !== 'object') {
- return inspectValue(value);
- }
- if (seen.has(value)) {
- return '[Circular]';
- }
- seen.add(value);
-
- const prototype = Object.getPrototypeOf(value);
- if (prototype === Object.prototype || prototype === null) {
- const result = {};
- for (const [key, entry] of Object.entries(value)) {
- result[key] = toSerializable(entry, seen);
- }
- return result;
- }
-
- return inspectValue(value);
-}
-
-function normalizeOrigin(value) {
- const normalized = String(value || '').trim();
- if (!normalized) {
- return '';
- }
-
- try {
- const parsed = new URL(normalized);
- if (!['http:', 'https:'].includes(parsed.protocol)) {
- return '';
- }
- return parsed.origin;
- } catch {
- return '';
- }
-}
-
-function normalizePermissionList(value) {
- const source = Array.isArray(value) ? value : value == null ? [] : [value];
- const result = [];
- const seen = new Set();
-
- for (const item of source) {
- const normalized = String(item || '').trim();
- if (!normalized || seen.has(normalized)) {
- continue;
- }
- seen.add(normalized);
- result.push(normalized);
- }
-
- return result;
-}
-
-function normalizePageAPIHeaders(value) {
- const headers = {};
- if (!value) {
- return headers;
- }
-
- if (typeof value.forEach === 'function') {
- value.forEach((entryValue, entryKey) => {
- const key = String(entryKey || '').trim();
- if (key) {
- headers[key] = String(entryValue);
- }
- });
- return headers;
- }
-
- if (Array.isArray(value)) {
- for (const entry of value) {
- if (!Array.isArray(entry) || entry.length < 2) {
- continue;
- }
- const key = String(entry[0] || '').trim();
- if (key) {
- headers[key] = String(entry[1]);
- }
- }
- return headers;
- }
-
- if (isPlainObject(value)) {
- for (const [key, entryValue] of Object.entries(value)) {
- const normalizedKey = String(key || '').trim();
- if (normalizedKey && entryValue !== undefined && entryValue !== null) {
- headers[normalizedKey] = String(entryValue);
- }
- }
- }
- return headers;
-}
-
-function setPageAPIHeaderIfAbsent(headers, key, value) {
- const normalizedKey = String(key || '').trim();
- if (!normalizedKey) {
- return;
- }
- const lowerKey = normalizedKey.toLowerCase();
- if (Object.keys(headers).some((existingKey) => existingKey.toLowerCase() === lowerKey)) {
- return;
- }
- headers[normalizedKey] = value;
-}
-
-function appendPageAPIQuery(rawURL, query) {
- if (!isPlainObject(query) && !Array.isArray(query)) {
- return rawURL;
- }
-
- const searchParams = new URLSearchParams();
- const appendEntry = (key, value) => {
- const normalizedKey = String(key || '').trim();
- if (!normalizedKey || value === undefined || value === null) {
- return;
- }
- if (Array.isArray(value)) {
- for (const item of value) {
- appendEntry(normalizedKey, item);
- }
- return;
- }
- searchParams.append(normalizedKey, String(value));
- };
-
- if (Array.isArray(query)) {
- for (const entry of query) {
- if (Array.isArray(entry) && entry.length >= 2) {
- appendEntry(entry[0], entry[1]);
- }
- }
- } else {
- for (const [key, value] of Object.entries(query)) {
- appendEntry(key, value);
- }
- }
-
- const queryText = searchParams.toString();
- if (!queryText) {
- return rawURL;
- }
-
- const hashIndex = rawURL.indexOf('#');
- const baseURL = hashIndex >= 0 ? rawURL.slice(0, hashIndex) : rawURL;
- const hash = hashIndex >= 0 ? rawURL.slice(hashIndex) : '';
- const separator = baseURL.includes('?')
- ? baseURL.endsWith('?') || baseURL.endsWith('&')
- ? ''
- : '&'
- : '?';
- return `${baseURL}${separator}${queryText}${hash}`;
-}
-
-function normalizePageAPICredentials(value) {
- const normalized = String(value || '').trim();
- if (['include', 'same-origin', 'omit'].includes(normalized)) {
- return normalized;
- }
- return 'include';
-}
-
-function normalizePageAPIBody(source, headers) {
- if (hasOwnProperty(source, 'bodyText')) {
- return source.bodyText == null ? null : String(source.bodyText);
- }
-
- if (hasOwnProperty(source, 'json')) {
- setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json');
- return JSON.stringify(source.json == null ? null : source.json);
- }
-
- if (!hasOwnProperty(source, 'body')) {
- return null;
- }
-
- const body = source.body;
- if (body == null) {
- return null;
- }
- if (typeof body === 'string') {
- return body;
- }
- setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json');
- return JSON.stringify(body);
-}
-
-function normalizePageAPIRequest(urlOrRequest, options = {}) {
- const base = isPlainObject(urlOrRequest) ? urlOrRequest : { url: urlOrRequest };
- const source = {
- ...base,
- ...(isPlainObject(options) ? options : {}),
- };
- const headers = normalizePageAPIHeaders(source.headers);
- const bodyText = normalizePageAPIBody(source, headers);
- const method = String(
- source.method || (bodyText == null ? 'GET' : 'POST')
- )
- .trim()
- .toUpperCase();
- const url = appendPageAPIQuery(String(source.url || '').trim(), source.query || source.searchParams);
-
- if (!url) {
- throw new Error('page api url is required');
- }
- if ((method === 'GET' || method === 'HEAD') && bodyText != null) {
- throw new Error(`${method} page api request cannot include a body`);
- }
-
- return {
- url,
- method,
- headers,
- credentials: normalizePageAPICredentials(source.credentials),
- bodyText,
- timeoutMs: normalizeTimeout(source.timeoutMs, 30000),
- parseJSON: source.parseJSON !== false,
- throwOnError: source.throwOnError === true || source.throwOnHTTPError === true,
- };
-}
-
-async function executePageAPIRequest(request) {
- const headers = request && request.headers && typeof request.headers === 'object'
- ? request.headers
- : {};
- const init = {
- method: request.method || 'GET',
- headers,
- credentials: request.credentials || 'include',
- };
-
- let timeoutID = null;
- if (request.timeoutMs > 0 && typeof AbortController !== 'undefined') {
- const controller = new AbortController();
- init.signal = controller.signal;
- timeoutID = setTimeout(() => controller.abort(), request.timeoutMs);
- }
-
- if (request.bodyText !== null && request.bodyText !== undefined) {
- init.body = request.bodyText;
- }
-
- try {
- const response = await fetch(request.url, init);
- const responseHeaders = {};
- if (response.headers && typeof response.headers.forEach === 'function') {
- response.headers.forEach((value, key) => {
- responseHeaders[key] = value;
- });
- }
-
- const bodyText = await response.text();
- let bodyJSON = null;
- let hasBodyJSON = false;
- if (request.parseJSON !== false && String(bodyText || '').trim()) {
- try {
- bodyJSON = JSON.parse(bodyText);
- hasBodyJSON = true;
- } catch {}
- }
-
- return {
- ok: response.ok,
- status: response.status,
- statusText: response.statusText,
- url: response.url,
- headers: responseHeaders,
- bodyText,
- bodyJSON: hasBodyJSON ? bodyJSON : null,
- json: hasBodyJSON ? bodyJSON : null,
- error: response.ok ? '' : response.statusText || `HTTP ${response.status}`,
- };
- } catch (error) {
- const message = error && error.message ? error.message : String(error);
- return {
- ok: false,
- status: 0,
- statusText: '',
- url: request.url,
- headers: {},
- bodyText: '',
- bodyJSON: null,
- json: null,
- error: message,
- };
- } finally {
- if (timeoutID) {
- clearTimeout(timeoutID);
- }
- }
-}
-
function buildLaunchRequestBody(defaultSelector, options) {
const launchOptions = options && typeof options === 'object' ? options : {};
const body = {};
@@ -529,63 +54,6 @@ function buildLaunchRequestBody(defaultSelector, options) {
return body;
}
-async function loadScriptModule(scriptPath) {
- const resolvedPath = path.resolve(String(scriptPath || ''));
- if (!resolvedPath) {
- throw new Error('scriptPath is required');
- }
-
- let requiredModule = null;
- let requireError = null;
- try {
- requiredModule = require(resolvedPath);
- } catch (error) {
- requireError = error;
- }
-
- const imported = async () => {
- const moduleURL = pathToFileURL(resolvedPath).href;
- return await import(`${moduleURL}?t=${Date.now()}`);
- };
-
- if (requiredModule && typeof requiredModule.run === 'function') {
- return requiredModule;
- }
- if (typeof requiredModule === 'function') {
- return { run: requiredModule };
- }
- if (requiredModule && requiredModule.default && typeof requiredModule.default.run === 'function') {
- return requiredModule.default;
- }
-
- try {
- const importedModule = await imported();
- if (importedModule && typeof importedModule.run === 'function') {
- return importedModule;
- }
- if (importedModule && typeof importedModule.default === 'function') {
- return { run: importedModule.default };
- }
- if (
- importedModule &&
- importedModule.default &&
- typeof importedModule.default.run === 'function'
- ) {
- return importedModule.default;
- }
- } catch (importError) {
- if (requireError) {
- throw requireError;
- }
- throw importError;
- }
-
- if (requireError) {
- throw requireError;
- }
- throw new Error('script must export run()');
-}
-
async function runScriptTask(payload, chromium) {
const scriptModule = await loadScriptModule(payload.scriptPath);
if (!scriptModule || typeof scriptModule.run !== 'function') {
diff --git a/backend/internal/automation/assets/runner_page_api.cjs b/backend/internal/automation/assets/runner_page_api.cjs
new file mode 100644
index 00000000..fcd207c2
--- /dev/null
+++ b/backend/internal/automation/assets/runner_page_api.cjs
@@ -0,0 +1,285 @@
+const {
+ normalizeTimeout,
+ isPlainObject,
+ hasOwnProperty,
+ requestJSON,
+} = require('./runner_shared.cjs');
+
+function normalizeOrigin(value) {
+ const normalized = String(value || '').trim();
+ if (!normalized) {
+ return '';
+ }
+
+ try {
+ const parsed = new URL(normalized);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return '';
+ }
+ return parsed.origin;
+ } catch {
+ return '';
+ }
+}
+
+function normalizePermissionList(value) {
+ const source = Array.isArray(value) ? value : value == null ? [] : [value];
+ const result = [];
+ const seen = new Set();
+
+ for (const item of source) {
+ const normalized = String(item || '').trim();
+ if (!normalized || seen.has(normalized)) {
+ continue;
+ }
+ seen.add(normalized);
+ result.push(normalized);
+ }
+
+ return result;
+}
+
+function normalizePageAPIHeaders(value) {
+ const headers = {};
+ if (!value) {
+ return headers;
+ }
+
+ if (typeof value.forEach === 'function') {
+ value.forEach((entryValue, entryKey) => {
+ const key = String(entryKey || '').trim();
+ if (key) {
+ headers[key] = String(entryValue);
+ }
+ });
+ return headers;
+ }
+
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ if (!Array.isArray(entry) || entry.length < 2) {
+ continue;
+ }
+ const key = String(entry[0] || '').trim();
+ if (key) {
+ headers[key] = String(entry[1]);
+ }
+ }
+ return headers;
+ }
+
+ if (isPlainObject(value)) {
+ for (const [key, entryValue] of Object.entries(value)) {
+ const normalizedKey = String(key || '').trim();
+ if (normalizedKey && entryValue !== undefined && entryValue !== null) {
+ headers[normalizedKey] = String(entryValue);
+ }
+ }
+ }
+ return headers;
+}
+
+function setPageAPIHeaderIfAbsent(headers, key, value) {
+ const normalizedKey = String(key || '').trim();
+ if (!normalizedKey) {
+ return;
+ }
+ const lowerKey = normalizedKey.toLowerCase();
+ if (Object.keys(headers).some((existingKey) => existingKey.toLowerCase() === lowerKey)) {
+ return;
+ }
+ headers[normalizedKey] = value;
+}
+
+function appendPageAPIQuery(rawURL, query) {
+ if (!isPlainObject(query) && !Array.isArray(query)) {
+ return rawURL;
+ }
+
+ const searchParams = new URLSearchParams();
+ const appendEntry = (key, value) => {
+ const normalizedKey = String(key || '').trim();
+ if (!normalizedKey || value === undefined || value === null) {
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ appendEntry(normalizedKey, item);
+ }
+ return;
+ }
+ searchParams.append(normalizedKey, String(value));
+ };
+
+ if (Array.isArray(query)) {
+ for (const entry of query) {
+ if (Array.isArray(entry) && entry.length >= 2) {
+ appendEntry(entry[0], entry[1]);
+ }
+ }
+ } else {
+ for (const [key, value] of Object.entries(query)) {
+ appendEntry(key, value);
+ }
+ }
+
+ const queryText = searchParams.toString();
+ if (!queryText) {
+ return rawURL;
+ }
+
+ const hashIndex = rawURL.indexOf('#');
+ const baseURL = hashIndex >= 0 ? rawURL.slice(0, hashIndex) : rawURL;
+ const hash = hashIndex >= 0 ? rawURL.slice(hashIndex) : '';
+ const separator = baseURL.includes('?')
+ ? baseURL.endsWith('?') || baseURL.endsWith('&')
+ ? ''
+ : '&'
+ : '?';
+ return `${baseURL}${separator}${queryText}${hash}`;
+}
+
+function normalizePageAPICredentials(value) {
+ const normalized = String(value || '').trim();
+ if (['include', 'same-origin', 'omit'].includes(normalized)) {
+ return normalized;
+ }
+ return 'include';
+}
+
+function normalizePageAPIBody(source, headers) {
+ if (hasOwnProperty(source, 'bodyText')) {
+ return source.bodyText == null ? null : String(source.bodyText);
+ }
+
+ if (hasOwnProperty(source, 'json')) {
+ setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json');
+ return JSON.stringify(source.json == null ? null : source.json);
+ }
+
+ if (!hasOwnProperty(source, 'body')) {
+ return null;
+ }
+
+ const body = source.body;
+ if (body == null) {
+ return null;
+ }
+ if (typeof body === 'string') {
+ return body;
+ }
+ setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json');
+ return JSON.stringify(body);
+}
+
+function normalizePageAPIRequest(urlOrRequest, options = {}) {
+ const base = isPlainObject(urlOrRequest) ? urlOrRequest : { url: urlOrRequest };
+ const source = {
+ ...base,
+ ...(isPlainObject(options) ? options : {}),
+ };
+ const headers = normalizePageAPIHeaders(source.headers);
+ const bodyText = normalizePageAPIBody(source, headers);
+ const method = String(
+ source.method || (bodyText == null ? 'GET' : 'POST')
+ )
+ .trim()
+ .toUpperCase();
+ const url = appendPageAPIQuery(String(source.url || '').trim(), source.query || source.searchParams);
+
+ if (!url) {
+ throw new Error('page api url is required');
+ }
+ if ((method === 'GET' || method === 'HEAD') && bodyText != null) {
+ throw new Error(`${method} page api request cannot include a body`);
+ }
+
+ return {
+ url,
+ method,
+ headers,
+ credentials: normalizePageAPICredentials(source.credentials),
+ bodyText,
+ timeoutMs: normalizeTimeout(source.timeoutMs, 30000),
+ parseJSON: source.parseJSON !== false,
+ throwOnError: source.throwOnError === true || source.throwOnHTTPError === true,
+ };
+}
+
+async function executePageAPIRequest(request) {
+ const headers = request && request.headers && typeof request.headers === 'object'
+ ? request.headers
+ : {};
+ const init = {
+ method: request.method || 'GET',
+ headers,
+ credentials: request.credentials || 'include',
+ };
+
+ let timeoutID = null;
+ if (request.timeoutMs > 0 && typeof AbortController !== 'undefined') {
+ const controller = new AbortController();
+ init.signal = controller.signal;
+ timeoutID = setTimeout(() => controller.abort(), request.timeoutMs);
+ }
+
+ if (request.bodyText !== null && request.bodyText !== undefined) {
+ init.body = request.bodyText;
+ }
+
+ try {
+ const response = await fetch(request.url, init);
+ const responseHeaders = {};
+ if (response.headers && typeof response.headers.forEach === 'function') {
+ response.headers.forEach((value, key) => {
+ responseHeaders[key] = value;
+ });
+ }
+
+ const bodyText = await response.text();
+ let bodyJSON = null;
+ let hasBodyJSON = false;
+ if (request.parseJSON !== false && String(bodyText || '').trim()) {
+ try {
+ bodyJSON = JSON.parse(bodyText);
+ hasBodyJSON = true;
+ } catch {}
+ }
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ url: response.url,
+ headers: responseHeaders,
+ bodyText,
+ bodyJSON: hasBodyJSON ? bodyJSON : null,
+ json: hasBodyJSON ? bodyJSON : null,
+ error: response.ok ? '' : response.statusText || `HTTP ${response.status}`,
+ };
+ } catch (error) {
+ const message = error && error.message ? error.message : String(error);
+ return {
+ ok: false,
+ status: 0,
+ statusText: '',
+ url: request.url,
+ headers: {},
+ bodyText: '',
+ bodyJSON: null,
+ json: null,
+ error: message,
+ };
+ } finally {
+ if (timeoutID) {
+ clearTimeout(timeoutID);
+ }
+ }
+}
+
+module.exports = {
+ normalizeOrigin,
+ normalizePermissionList,
+ normalizePageAPIRequest,
+ executePageAPIRequest,
+};
\ No newline at end of file
diff --git a/backend/internal/automation/assets/runner_script_loader.cjs b/backend/internal/automation/assets/runner_script_loader.cjs
new file mode 100644
index 00000000..4c7b207c
--- /dev/null
+++ b/backend/internal/automation/assets/runner_script_loader.cjs
@@ -0,0 +1,61 @@
+const path = require('path');
+const { pathToFileURL } = require('url');
+
+async function loadScriptModule(scriptPath) {
+ const resolvedPath = path.resolve(String(scriptPath || ''));
+ if (!resolvedPath) {
+ throw new Error('scriptPath is required');
+ }
+
+ let requiredModule = null;
+ let requireError = null;
+ try {
+ requiredModule = require(resolvedPath);
+ } catch (error) {
+ requireError = error;
+ }
+
+ const imported = async () => {
+ const moduleURL = pathToFileURL(resolvedPath).href;
+ return await import(`${moduleURL}?t=${Date.now()}`);
+ };
+
+ if (requiredModule && typeof requiredModule.run === 'function') {
+ return requiredModule;
+ }
+ if (typeof requiredModule === 'function') {
+ return { run: requiredModule };
+ }
+ if (requiredModule && requiredModule.default && typeof requiredModule.default.run === 'function') {
+ return requiredModule.default;
+ }
+
+ try {
+ const importedModule = await imported();
+ if (importedModule && typeof importedModule.run === 'function') {
+ return importedModule;
+ }
+ if (importedModule && typeof importedModule.default === 'function') {
+ return { run: importedModule.default };
+ }
+ if (
+ importedModule &&
+ importedModule.default &&
+ typeof importedModule.default.run === 'function'
+ ) {
+ return importedModule.default;
+ }
+ } catch (importError) {
+ if (requireError) {
+ throw requireError;
+ }
+ throw importError;
+ }
+
+ if (requireError) {
+ throw requireError;
+ }
+ throw new Error('script must export run()');
+}
+
+module.exports = { loadScriptModule };
\ No newline at end of file
diff --git a/backend/internal/automation/assets/runner_shared.cjs b/backend/internal/automation/assets/runner_shared.cjs
new file mode 100644
index 00000000..65b48178
--- /dev/null
+++ b/backend/internal/automation/assets/runner_shared.cjs
@@ -0,0 +1,232 @@
+const fs = require('fs');
+const http = require('http');
+const https = require('https');
+const path = require('path');
+const util = require('util');
+
+const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']);
+
+function normalizeTimeout(value, fallback) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.round(parsed);
+ }
+ return fallback;
+}
+
+function isPlainObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+}
+
+function hasOwnProperty(value, key) {
+ return Object.prototype.hasOwnProperty.call(value, key);
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function writeStream(stream, text) {
+ return new Promise((resolve, reject) => {
+ stream.write(text, (error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+}
+
+async function closeBrowserConnection(browser) {
+ if (!browser || typeof browser.close !== 'function') {
+ return;
+ }
+ await browser.close({ reason: 'automation task finished' }).catch(() => {});
+}
+
+function normalizeEndpointCandidate(value) {
+ const normalized = String(value || '').trim();
+ if (!normalized) {
+ return '';
+ }
+
+ try {
+ const parsed = new URL(normalized);
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) {
+ return '';
+ }
+ if (parsed.port === '0') {
+ return '';
+ }
+ if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (!parsed.pathname || parsed.pathname === '/') && !parsed.search && !parsed.hash) {
+ return parsed.origin;
+ }
+ return parsed.toString();
+ } catch {
+ return '';
+ }
+}
+
+function buildConnectEndpoints(payload, session) {
+ const candidates = [];
+ const seen = new Set();
+
+ const pushCandidate = (value) => {
+ const endpoint = normalizeEndpointCandidate(value);
+ if (!endpoint || seen.has(endpoint)) {
+ return;
+ }
+ seen.add(endpoint);
+ candidates.push(endpoint);
+ };
+
+ pushCandidate(session && session.cdpUrl);
+
+ const debugPort = Number(session && session.debugPort);
+ if (Number.isFinite(debugPort) && debugPort > 0) {
+ pushCandidate(`http://127.0.0.1:${Math.round(debugPort)}`);
+ }
+
+ pushCandidate(payload && payload.launchBaseUrl);
+ return candidates;
+}
+
+function normalizePathUnderRoot(rootDir, targetName) {
+ const normalizedName = String(targetName || '').trim();
+ const resolvedRoot = path.resolve(String(rootDir || ''));
+ if (!resolvedRoot) {
+ throw new Error('artifactDir is required');
+ }
+
+ const candidate = normalizedName ? path.resolve(resolvedRoot, normalizedName) : resolvedRoot;
+ if (candidate !== resolvedRoot && !candidate.startsWith(`${resolvedRoot}${path.sep}`)) {
+ throw new Error('artifact path escapes root directory');
+ }
+ return candidate;
+}
+
+async function requestJSON(method, requestURL, body, headers = {}) {
+ const target = new URL(requestURL);
+ const transport = target.protocol === 'https:' ? https : http;
+ const payload = body == null ? '' : JSON.stringify(body);
+
+ return await new Promise((resolve, reject) => {
+ const req = transport.request(
+ {
+ protocol: target.protocol,
+ hostname: target.hostname,
+ port: target.port,
+ path: `${target.pathname}${target.search}`,
+ method,
+ headers: {
+ Accept: 'application/json',
+ ...(payload
+ ? {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payload),
+ }
+ : {}),
+ ...headers,
+ },
+ },
+ (res) => {
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => {
+ const rawText = Buffer.concat(chunks).toString('utf8').trim();
+ let responseBody = {};
+ if (rawText) {
+ try {
+ responseBody = JSON.parse(rawText);
+ } catch {
+ responseBody = { rawBody: rawText };
+ }
+ }
+ resolve({
+ status: res.statusCode || 0,
+ body: responseBody,
+ });
+ });
+ }
+ );
+
+ req.on('error', reject);
+ if (payload) {
+ req.write(payload);
+ }
+ req.end();
+ });
+}
+
+function inspectValue(value) {
+ return util.inspect(value, {
+ depth: 4,
+ breakLength: 120,
+ maxArrayLength: 20,
+ compact: false,
+ });
+}
+
+function toSerializable(value, seen = new WeakSet()) {
+ if (value == null) {
+ return value;
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'bigint') {
+ return value.toString();
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ if (value instanceof Error) {
+ return {
+ name: value.name,
+ message: value.message,
+ stack: value.stack,
+ };
+ }
+ if (Buffer.isBuffer(value)) {
+ return value.toString('utf8');
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => toSerializable(item, seen));
+ }
+ if (typeof value === 'function') {
+ return `[Function ${value.name || 'anonymous'}]`;
+ }
+ if (typeof value !== 'object') {
+ return inspectValue(value);
+ }
+ if (seen.has(value)) {
+ return '[Circular]';
+ }
+ seen.add(value);
+
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype === Object.prototype || prototype === null) {
+ const result = {};
+ for (const [key, entry] of Object.entries(value)) {
+ result[key] = toSerializable(entry, seen);
+ }
+ return result;
+ }
+
+ return inspectValue(value);
+}
+
+
+module.exports = {
+ normalizeTimeout,
+ isPlainObject,
+ hasOwnProperty,
+ sleep,
+ writeStream,
+ closeBrowserConnection,
+ buildConnectEndpoints,
+ normalizePathUnderRoot,
+ requestJSON,
+ toSerializable,
+};
\ No newline at end of file
diff --git a/backend/internal/automation/builtin_script_library_test.go b/backend/internal/automation/builtin_script_library_test.go
index de1265f1..686596cb 100644
--- a/backend/internal/automation/builtin_script_library_test.go
+++ b/backend/internal/automation/builtin_script_library_test.go
@@ -58,8 +58,9 @@ func TestDefaultScriptBundles(t *testing.T) {
t.Fatalf("expected public api to be enabled for %q", bundle.Record.ID)
}
if bundle.Record.ID == WebImageGenerateScriptID {
- if len(bundle.Record.PublicAPI.Variables) != 1 || bundle.Record.PublicAPI.Variables[0].Name != "prompt" {
- t.Fatalf("expected web image script to expose prompt variable, got %+v", bundle.Record.PublicAPI.Variables)
+ variables := bundle.Record.PublicAPI.Variables
+ if len(variables) != 2 || variables[0].Name != "code" || variables[1].Name != "prompt" {
+ t.Fatalf("expected web image script to expose code and prompt variables, got %+v", variables)
}
}
if len(bundle.Files) == 0 {
diff --git a/backend/internal/automation/demo-library/news-query-txt/index.cjs b/backend/internal/automation/demo-library/news-query-txt/index.cjs
index 96b755bc..1a9e0a8d 100644
--- a/backend/internal/automation/demo-library/news-query-txt/index.cjs
+++ b/backend/internal/automation/demo-library/news-query-txt/index.cjs
@@ -1,384 +1,18 @@
-const fs = require('fs')
-
-const DEFAULT_EXCLUDED_DOMAINS = [
- 'zhihu.com',
- 'baidu.com',
- 'qq.com',
- '36kr.com',
- 'apifox.com',
- 'chatgpt-chinese.com',
- 'openwebui.cn',
- 'open-openai.com',
- 'xiniushu.com',
- 'reddit.com',
- 'quora.com',
- 'tieba.baidu.com',
- 'weibo.com',
- 'x.com',
- 'twitter.com',
- 'youtube.com',
- 'bilibili.com',
- 'douyin.com',
- 'xiaohongshu.com',
-]
-
-function normalizeInt(value, fallback, min, max) {
- const parsed = Number(value)
- if (!Number.isFinite(parsed)) {
- return fallback
- }
-
- const rounded = Math.round(parsed)
- if (rounded < min) {
- return min
- }
- if (rounded > max) {
- return max
- }
- return rounded
-}
-
-function normalizeText(value) {
- return String(value || '').trim()
-}
-
-function normalizeDomainList(value) {
- if (!Array.isArray(value)) {
- return []
- }
-
- const deduped = new Set()
- for (const item of value) {
- const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase()
- if (normalized) {
- deduped.add(normalized)
- }
- }
- return Array.from(deduped)
-}
-
-function buildDefaultQuery(keyword) {
- const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
- if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
- return normalizedKeyword + ' 新闻'
- }
- return normalizedKeyword + ' news'
-}
-
-function buildFallbackQueries(keyword, baseQuery) {
- const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
- const normalizedBaseQuery = normalizeText(baseQuery)
- const candidates = [
- normalizedBaseQuery,
- ]
-
- if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
- candidates.push(normalizedKeyword + ' 最新新闻')
- } else {
- candidates.push(normalizedKeyword + ' latest news')
- }
-
- const deduped = new Set()
- for (const item of candidates) {
- const normalized = normalizeText(item)
- if (normalized) {
- deduped.add(normalized)
- }
- }
- return Array.from(deduped)
-}
-
-function buildSearchQuery(baseQuery, excludedDomains) {
- const normalizedBaseQuery = normalizeText(baseQuery)
- const normalizedDomains = normalizeDomainList(excludedDomains)
- const parts = [normalizedBaseQuery]
-
- for (const domain of normalizedDomains) {
- parts.push('-site:' + domain)
- }
-
- return parts.filter(Boolean).join(' ')
-}
-
-function mapTimeRangeToBingFilter(value) {
- switch (normalizeText(value).toLowerCase()) {
- case 'day':
- case '24h':
- case 'today':
- return 'ex1:"ez1"'
- case 'week':
- return 'ex1:"ez2"'
- case 'month':
- return 'ex1:"ez3"'
- default:
- return ''
- }
-}
-
-function buildSearchURL(query, timeRange, firstResultIndex) {
- const searchParams = new URLSearchParams({ q: query })
- const filter = mapTimeRangeToBingFilter(timeRange)
- if (filter) {
- searchParams.set('filters', filter)
- }
- if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) {
- searchParams.set('first', String(firstResultIndex))
- }
- return 'https://www.bing.com/search?' + searchParams.toString()
-}
-
-function splitSnippet(snippet) {
- const normalized = normalizeText(snippet)
- if (!normalized) {
- return { publishedAt: '', summary: '' }
- }
-
- const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/)
- if (
- match &&
- /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1])
- ) {
- return {
- publishedAt: normalizeText(match[1]),
- summary: normalizeText(match[2]),
- }
- }
-
- return {
- publishedAt: '',
- summary: normalized,
- }
-}
-
-function parseHostname(rawUrl) {
- const normalized = normalizeText(rawUrl)
- if (!normalized) {
- return ''
- }
-
- try {
- return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase()
- } catch {
- return ''
- }
-}
-
-function parsePathname(rawUrl) {
- const normalized = normalizeText(rawUrl)
- if (!normalized) {
- return ''
- }
-
- try {
- const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase()
- if (!pathname) {
- return ''
- }
- return pathname === '/' ? pathname : pathname.replace(/\/$/, '')
- } catch {
- return ''
- }
-}
-
-function looksLikeQuestionTitle(title) {
- const normalized = normalizeText(title)
- if (!normalized) {
- return false
- }
-
- if (/[??]/.test(normalized)) {
- return true
- }
-
- return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized)
-}
-
-function looksLikeAggregateText(text) {
- const normalized = normalizeText(text).toLowerCase()
- if (!normalized) {
- return false
- }
-
- return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized)
-}
-
-function looksLikeListingPath(pathname) {
- const normalized = normalizeText(pathname).toLowerCase()
- if (!normalized || normalized === '/') {
- return false
- }
-
- if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) {
- return true
- }
-
- if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) {
- return true
- }
-
- return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized)
-}
-
-function looksLikeListingText(text) {
- const normalized = normalizeText(text).toLowerCase()
- if (!normalized) {
- return false
- }
-
- return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized)
-}
-
-function isBlockedHostname(hostname) {
- const normalized = normalizeText(hostname).toLowerCase()
- if (!normalized) {
- return false
- }
-
- const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS
- const blockedKeywords = [
- 'aitrack',
- 'aitoolly',
- 'aiflashreport',
- 'llm-stats',
- 'opentools',
- ]
-
- if (blockedSuffixes.some(function (suffix) {
- return normalized === suffix || normalized.endsWith('.' + suffix)
- })) {
- return true
- }
-
- return blockedKeywords.some(function (keyword) {
- return normalized.includes(keyword)
- })
-}
-
-function evaluateNewsItem(item) {
- const hostname = parseHostname(item.url)
- const pathname = parsePathname(item.url)
- const summary = normalizeText(item.summary)
- const source = normalizeText(item.source)
- const reasons = []
-
- if (!normalizeText(item.url)) {
- reasons.push('missing-url')
- }
- if (!hostname) {
- reasons.push('invalid-url')
- }
- if (hostname && isBlockedHostname(hostname)) {
- reasons.push('blocked-host')
- }
- if (!source) {
- reasons.push('missing-source')
- }
- if (summary.length < 20) {
- reasons.push('summary-too-short')
- }
- if (looksLikeQuestionTitle(item.title)) {
- reasons.push('question-title')
- }
- if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) {
- reasons.push('aggregate-page')
- }
- if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) {
- reasons.push('listing-page')
- }
-
- return Object.assign({}, item, {
- hostname: hostname,
- pathname: pathname,
- qualityAccepted: reasons.length === 0,
- qualityReasons: reasons,
- })
-}
-
-function formatRejectedReason(reason) {
- switch (reason) {
- case 'missing-url':
- return '缺少链接'
- case 'invalid-url':
- return '链接无效'
- case 'blocked-host':
- return '来源站点已过滤'
- case 'missing-source':
- return '缺少来源'
- case 'summary-too-short':
- return '摘要过短'
- case 'question-title':
- return '标题更像问答'
- case 'aggregate-page':
- return '更像聚合页'
- case 'listing-page':
- return '更像列表页/专题页'
- default:
- return reason
- }
-}
-
-function formatReport(items, metadata) {
- const lines = [
- '新闻抓取结果',
- '查询词: ' + metadata.query,
- '抓取时间: ' + metadata.generatedAt,
- '搜索地址: ' + metadata.searchUrl,
- '原始结果: ' + metadata.rawCount,
- '通过校验: ' + items.length,
- '过滤数量: ' + metadata.rejectedItems.length,
- '',
- ]
-
- for (const item of items) {
- lines.push(item.rank + '. ' + item.title)
- if (item.source) {
- lines.push('来源: ' + item.source)
- }
- if (item.publishedAt) {
- lines.push('时间: ' + item.publishedAt)
- }
- lines.push('链接: ' + item.url)
- if (item.summary) {
- lines.push('摘要: ' + item.summary)
- }
- lines.push('')
- }
-
- if (metadata.rejectedItems.length > 0) {
- lines.push('被过滤结果(最多展示 5 条)')
- lines.push('')
- for (const item of metadata.rejectedItems.slice(0, 5)) {
- lines.push(item.rank + '. ' + item.title)
- if (item.hostname) {
- lines.push('站点: ' + item.hostname)
- }
- lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / '))
- lines.push('')
- }
- }
-
- return lines.join('\n')
-}
-
-function pickBestAttempt(current, candidate) {
- if (!current) {
- return candidate
- }
-
- if (candidate.acceptedItems.length !== current.acceptedItems.length) {
- return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current
- }
-
- if (candidate.distinctHostCount !== current.distinctHostCount) {
- return candidate.distinctHostCount > current.distinctHostCount ? candidate : current
- }
-
- if (candidate.rawItems.length !== current.rawItems.length) {
- return candidate.rawItems.length > current.rawItems.length ? candidate : current
- }
-
- return candidate
-}
+const fs = require('fs')
+const {
+ DEFAULT_EXCLUDED_DOMAINS,
+ normalizeInt,
+ normalizeText,
+ normalizeDomainList,
+ buildDefaultQuery,
+ buildFallbackQueries,
+ buildSearchQuery,
+ buildSearchURL,
+ splitSnippet,
+ evaluateNewsItem,
+ formatReport,
+ pickBestAttempt,
+} = require('./news-query-utils.cjs')
module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => {
const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000)
diff --git a/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs b/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs
new file mode 100644
index 00000000..2b075864
--- /dev/null
+++ b/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs
@@ -0,0 +1,395 @@
+const DEFAULT_EXCLUDED_DOMAINS = [
+ 'zhihu.com',
+ 'baidu.com',
+ 'qq.com',
+ '36kr.com',
+ 'apifox.com',
+ 'chatgpt-chinese.com',
+ 'openwebui.cn',
+ 'open-openai.com',
+ 'xiniushu.com',
+ 'reddit.com',
+ 'quora.com',
+ 'tieba.baidu.com',
+ 'weibo.com',
+ 'x.com',
+ 'twitter.com',
+ 'youtube.com',
+ 'bilibili.com',
+ 'douyin.com',
+ 'xiaohongshu.com',
+]
+
+function normalizeInt(value, fallback, min, max) {
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed)) {
+ return fallback
+ }
+
+ const rounded = Math.round(parsed)
+ if (rounded < min) {
+ return min
+ }
+ if (rounded > max) {
+ return max
+ }
+ return rounded
+}
+
+function normalizeText(value) {
+ return String(value || '').trim()
+}
+
+function normalizeDomainList(value) {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ const deduped = new Set()
+ for (const item of value) {
+ const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase()
+ if (normalized) {
+ deduped.add(normalized)
+ }
+ }
+ return Array.from(deduped)
+}
+
+function buildDefaultQuery(keyword) {
+ const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
+ if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
+ return normalizedKeyword + ' 新闻'
+ }
+ return normalizedKeyword + ' news'
+}
+
+function buildFallbackQueries(keyword, baseQuery) {
+ const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
+ const normalizedBaseQuery = normalizeText(baseQuery)
+ const candidates = [
+ normalizedBaseQuery,
+ ]
+
+ if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
+ candidates.push(normalizedKeyword + ' 最新新闻')
+ } else {
+ candidates.push(normalizedKeyword + ' latest news')
+ }
+
+ const deduped = new Set()
+ for (const item of candidates) {
+ const normalized = normalizeText(item)
+ if (normalized) {
+ deduped.add(normalized)
+ }
+ }
+ return Array.from(deduped)
+}
+
+function buildSearchQuery(baseQuery, excludedDomains) {
+ const normalizedBaseQuery = normalizeText(baseQuery)
+ const normalizedDomains = normalizeDomainList(excludedDomains)
+ const parts = [normalizedBaseQuery]
+
+ for (const domain of normalizedDomains) {
+ parts.push('-site:' + domain)
+ }
+
+ return parts.filter(Boolean).join(' ')
+}
+
+function mapTimeRangeToBingFilter(value) {
+ switch (normalizeText(value).toLowerCase()) {
+ case 'day':
+ case '24h':
+ case 'today':
+ return 'ex1:"ez1"'
+ case 'week':
+ return 'ex1:"ez2"'
+ case 'month':
+ return 'ex1:"ez3"'
+ default:
+ return ''
+ }
+}
+
+function buildSearchURL(query, timeRange, firstResultIndex) {
+ const searchParams = new URLSearchParams({ q: query })
+ const filter = mapTimeRangeToBingFilter(timeRange)
+ if (filter) {
+ searchParams.set('filters', filter)
+ }
+ if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) {
+ searchParams.set('first', String(firstResultIndex))
+ }
+ return 'https://www.bing.com/search?' + searchParams.toString()
+}
+
+function splitSnippet(snippet) {
+ const normalized = normalizeText(snippet)
+ if (!normalized) {
+ return { publishedAt: '', summary: '' }
+ }
+
+ const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/)
+ if (
+ match &&
+ /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1])
+ ) {
+ return {
+ publishedAt: normalizeText(match[1]),
+ summary: normalizeText(match[2]),
+ }
+ }
+
+ return {
+ publishedAt: '',
+ summary: normalized,
+ }
+}
+
+function parseHostname(rawUrl) {
+ const normalized = normalizeText(rawUrl)
+ if (!normalized) {
+ return ''
+ }
+
+ try {
+ return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase()
+ } catch {
+ return ''
+ }
+}
+
+function parsePathname(rawUrl) {
+ const normalized = normalizeText(rawUrl)
+ if (!normalized) {
+ return ''
+ }
+
+ try {
+ const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase()
+ if (!pathname) {
+ return ''
+ }
+ return pathname === '/' ? pathname : pathname.replace(/\/$/, '')
+ } catch {
+ return ''
+ }
+}
+
+function looksLikeQuestionTitle(title) {
+ const normalized = normalizeText(title)
+ if (!normalized) {
+ return false
+ }
+
+ if (/[??]/.test(normalized)) {
+ return true
+ }
+
+ return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized)
+}
+
+function looksLikeAggregateText(text) {
+ const normalized = normalizeText(text).toLowerCase()
+ if (!normalized) {
+ return false
+ }
+
+ return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized)
+}
+
+function looksLikeListingPath(pathname) {
+ const normalized = normalizeText(pathname).toLowerCase()
+ if (!normalized || normalized === '/') {
+ return false
+ }
+
+ if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) {
+ return true
+ }
+
+ if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) {
+ return true
+ }
+
+ return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized)
+}
+
+function looksLikeListingText(text) {
+ const normalized = normalizeText(text).toLowerCase()
+ if (!normalized) {
+ return false
+ }
+
+ return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized)
+}
+
+function isBlockedHostname(hostname) {
+ const normalized = normalizeText(hostname).toLowerCase()
+ if (!normalized) {
+ return false
+ }
+
+ const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS
+ const blockedKeywords = [
+ 'aitrack',
+ 'aitoolly',
+ 'aiflashreport',
+ 'llm-stats',
+ 'opentools',
+ ]
+
+ if (blockedSuffixes.some(function (suffix) {
+ return normalized === suffix || normalized.endsWith('.' + suffix)
+ })) {
+ return true
+ }
+
+ return blockedKeywords.some(function (keyword) {
+ return normalized.includes(keyword)
+ })
+}
+
+function evaluateNewsItem(item) {
+ const hostname = parseHostname(item.url)
+ const pathname = parsePathname(item.url)
+ const summary = normalizeText(item.summary)
+ const source = normalizeText(item.source)
+ const reasons = []
+
+ if (!normalizeText(item.url)) {
+ reasons.push('missing-url')
+ }
+ if (!hostname) {
+ reasons.push('invalid-url')
+ }
+ if (hostname && isBlockedHostname(hostname)) {
+ reasons.push('blocked-host')
+ }
+ if (!source) {
+ reasons.push('missing-source')
+ }
+ if (summary.length < 20) {
+ reasons.push('summary-too-short')
+ }
+ if (looksLikeQuestionTitle(item.title)) {
+ reasons.push('question-title')
+ }
+ if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) {
+ reasons.push('aggregate-page')
+ }
+ if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) {
+ reasons.push('listing-page')
+ }
+
+ return Object.assign({}, item, {
+ hostname: hostname,
+ pathname: pathname,
+ qualityAccepted: reasons.length === 0,
+ qualityReasons: reasons,
+ })
+}
+
+function formatRejectedReason(reason) {
+ switch (reason) {
+ case 'missing-url':
+ return '缺少链接'
+ case 'invalid-url':
+ return '链接无效'
+ case 'blocked-host':
+ return '来源站点已过滤'
+ case 'missing-source':
+ return '缺少来源'
+ case 'summary-too-short':
+ return '摘要过短'
+ case 'question-title':
+ return '标题更像问答'
+ case 'aggregate-page':
+ return '更像聚合页'
+ case 'listing-page':
+ return '更像列表页/专题页'
+ default:
+ return reason
+ }
+}
+
+function formatReport(items, metadata) {
+ const lines = [
+ '新闻抓取结果',
+ '查询词: ' + metadata.query,
+ '抓取时间: ' + metadata.generatedAt,
+ '搜索地址: ' + metadata.searchUrl,
+ '原始结果: ' + metadata.rawCount,
+ '通过校验: ' + items.length,
+ '过滤数量: ' + metadata.rejectedItems.length,
+ '',
+ ]
+
+ for (const item of items) {
+ lines.push(item.rank + '. ' + item.title)
+ if (item.source) {
+ lines.push('来源: ' + item.source)
+ }
+ if (item.publishedAt) {
+ lines.push('时间: ' + item.publishedAt)
+ }
+ lines.push('链接: ' + item.url)
+ if (item.summary) {
+ lines.push('摘要: ' + item.summary)
+ }
+ lines.push('')
+ }
+
+ if (metadata.rejectedItems.length > 0) {
+ lines.push('被过滤结果(最多展示 5 条)')
+ lines.push('')
+ for (const item of metadata.rejectedItems.slice(0, 5)) {
+ lines.push(item.rank + '. ' + item.title)
+ if (item.hostname) {
+ lines.push('站点: ' + item.hostname)
+ }
+ lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / '))
+ lines.push('')
+ }
+ }
+
+ return lines.join('\n')
+}
+
+function pickBestAttempt(current, candidate) {
+ if (!current) {
+ return candidate
+ }
+
+ if (candidate.acceptedItems.length !== current.acceptedItems.length) {
+ return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current
+ }
+
+ if (candidate.distinctHostCount !== current.distinctHostCount) {
+ return candidate.distinctHostCount > current.distinctHostCount ? candidate : current
+ }
+
+ if (candidate.rawItems.length !== current.rawItems.length) {
+ return candidate.rawItems.length > current.rawItems.length ? candidate : current
+ }
+
+ return candidate
+}
+
+
+module.exports = {
+ DEFAULT_EXCLUDED_DOMAINS,
+ normalizeInt,
+ normalizeText,
+ normalizeDomainList,
+ buildDefaultQuery,
+ buildFallbackQueries,
+ buildSearchQuery,
+ buildSearchURL,
+ splitSnippet,
+ evaluateNewsItem,
+ formatReport,
+ pickBestAttempt,
+};
\ No newline at end of file
diff --git a/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json
index a01ecf5e..ea81ff18 100644
--- a/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json
+++ b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json
@@ -1,58 +1,50 @@
{
- "format": "ant-automation-script",
- "packageFormat": "ant-automation-script",
- "manifestVersion": 1,
- "id": "web-image-generate-download",
- "name": "网页图片生成并下载",
- "description": "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。",
- "type": "playwright-cdp",
- "status": "draft",
- "entryFile": "index.cjs",
- "tags": [
- "Playwright",
- "图片生成",
- "下载",
- "脚手架"
- ],
- "params": {
- "pageUrl": "https://chatgpt.com/",
- "prompt": "A cinematic chrome ant browser mascot, premium product lighting",
- "outputFileName": "generated-image.png",
- "selectors": {
- "newSessionButton": "",
- "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]",
- "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color",
- "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]",
- "downloadButton": ""
- },
- "timeoutMs": 300000,
- "waitAfterLoadMs": 1200,
- "settleMs": 2500,
- "captureScreenshot": false
- },
- "notes": "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。",
- "publicAPI": {
- "enabled": true,
- "method": "POST",
- "path": "image/chatgpt-generate-download",
- "requestMode": "standard",
- "responseMode": "envelope",
- "timeoutMs": 300000,
- "requestBodyText": "{\n \"params\": {\n \"prompt\": \"{{prompt}}\"\n }\n}",
- "responseBodyText": "{\n \"ok\": true,\n \"outputPath\": \"${artifactsDir}/generated-image.png\",\n \"downloadAddress\": \"${artifactsDir}/generated-image.png\"\n}",
- "variables": [
- {
- "name": "prompt",
- "defaultValue": "A cinematic chrome ant browser mascot, premium product lighting",
- "description": "发送到 ChatGPT 的图片生成提示词。",
- "required": true
- }
- ]
- },
- "source": {
- "type": "builtin",
- "uri": "repo://backend/internal/automation/demo-library/web-image-generate-download",
- "ref": "HEAD",
- "path": "web-image-generate-download"
- }
+ "format": "ant-automation-script",
+ "packageFormat": "ant-automation-script",
+ "manifestVersion": 1,
+ "id": "web-image-generate-download",
+ "name": "网页图片生成并下载",
+ "description": "打开 ChatGPT,发送图片生成消息,等待图片生成后下载图片。",
+ "type": "playwright-cdp",
+ "status": "draft",
+ "entryFile": "index.cjs",
+ "tags": [
+ "Playwright",
+ "图片生成",
+ "下载"
+ ],
+ "params": {
+ "prompt": "A cinematic chrome ant browser mascot, premium product lighting"
+ },
+ "notes": "脚本默认打开 ChatGPT,输入图片生成提示词并发送;页面选择器、下载文件名等由脚本内部默认值处理,公开接口只需要传实例、提示词和超时时间。",
+ "publicAPI": {
+ "enabled": true,
+ "method": "POST",
+ "path": "image/chatgpt-generate-download",
+ "requestMode": "standard",
+ "responseMode": "envelope",
+ "timeoutMs": 300000,
+ "requestBodyText": "{\n \"instance\": {\n \"type\": \"existing\",\n \"selector\": {\n \"code\": \"{{code}}\"\n }\n },\n \"params\": {\n \"prompt\": \"{{prompt}}\"\n },\n \"timeoutMs\": 300000\n}",
+ "responseBodyText": "{\n \"ok\": true,\n \"status\": \"completed\",\n \"summary\": \"图片已生成并下载。\",\n \"outputPath\": \"${artifactsDir}/generated-image.png\",\n \"downloadAddress\": \"${artifactsDir}/generated-image.png\"\n}",
+ "variables": [
+ {
+ "name": "code",
+ "defaultValue": "BUYER_001",
+ "description": "要使用的浏览器实例启动码。",
+ "required": true
+ },
+ {
+ "name": "prompt",
+ "defaultValue": "A cinematic chrome ant browser mascot, premium product lighting",
+ "description": "发送到 ChatGPT 的图片生成提示词。",
+ "required": true
+ }
+ ]
+ },
+ "source": {
+ "type": "builtin",
+ "uri": "repo://backend/internal/automation/demo-library/web-image-generate-download",
+ "ref": "HEAD",
+ "path": "web-image-generate-download"
+ }
}
diff --git a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs
index 5f6b2eef..791ba808 100644
--- a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs
+++ b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs
@@ -40,6 +40,25 @@ function resolveOutputPath(outputDir, outputFileName) {
return path.join(outputDir || process.cwd(), safeName)
}
+const DEFAULT_SELECTORS = {
+ newSessionButton: '',
+ promptInput: '#prompt-textarea[contenteditable="true"], textarea[name="prompt-textarea"]',
+ sendButton: 'button[data-testid="send-button"], button[aria-label*="发送"], button.composer-submit-button-color',
+ generatedImage: 'img[src*="/backend-api/estuary/content"], img[alt*="已生成图片"], img[src*="oaiusercontent"], img[src*="oaidalleapiprodscus"], img[alt*="生成"], img[alt*="image" i]',
+ downloadButton: '',
+}
+
+function normalizeSelectors(value) {
+ const selectors = value && typeof value === 'object' ? value : {}
+ return {
+ newSessionButton: normalizeText(selectors.newSessionButton),
+ promptInput: normalizeText(selectors.promptInput) || DEFAULT_SELECTORS.promptInput,
+ sendButton: normalizeText(selectors.sendButton) || DEFAULT_SELECTORS.sendButton,
+ generatedImage: normalizeText(selectors.generatedImage) || DEFAULT_SELECTORS.generatedImage,
+ downloadButton: normalizeText(selectors.downloadButton),
+ }
+}
+
function buildMissingSetup(selectors, pageUrl) {
const missing = []
if (!pageUrl) {
@@ -208,7 +227,7 @@ async function captureScreenshotIfNeeded(page, enabled, outputDir, label) {
exports.run = async function run({ useBrowser, params = {}, artifact, artifactsDir, log }) {
const pageUrl = normalizeText(params.pageUrl || params.url) || 'https://chatgpt.com/'
const prompt = normalizeText(params.prompt) || 'A cinematic chrome ant browser mascot, premium product lighting'
- const selectors = params.selectors && typeof params.selectors === 'object' ? params.selectors : {}
+ const selectors = normalizeSelectors(params.selectors)
const timeoutMs = normalizeInt(params.timeoutMs, 300000, 5000, 900000)
const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1200, 0, 30000)
const settleMs = normalizeInt(params.settleMs, 2500, 0, 60000)
@@ -224,7 +243,7 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD
return {
ok: false,
status: 'needs_page_info',
- summary: '网页图片生成脚手架已创建,等待补充页面 URL 和选择器。',
+ summary: '网页图片生成缺少必要页面配置。',
missing,
expectedFlow: [
'open_page',
diff --git a/backend/internal/automation/runner_asset.go b/backend/internal/automation/runner_asset.go
index 8499faf2..0fd9d913 100644
--- a/backend/internal/automation/runner_asset.go
+++ b/backend/internal/automation/runner_asset.go
@@ -6,3 +6,19 @@ const runnerScriptFileName = "runner.cjs"
//go:embed assets/runner.cjs
var runnerScriptContent []byte
+
+//go:embed assets/runner_shared.cjs
+var runnerSharedScriptContent []byte
+
+//go:embed assets/runner_page_api.cjs
+var runnerPageAPIScriptContent []byte
+
+//go:embed assets/runner_script_loader.cjs
+var runnerScriptLoaderContent []byte
+
+var runnerAssetFiles = map[string][]byte{
+ runnerScriptFileName: runnerScriptContent,
+ "runner_shared.cjs": runnerSharedScriptContent,
+ "runner_page_api.cjs": runnerPageAPIScriptContent,
+ "runner_script_loader.cjs": runnerScriptLoaderContent,
+}
diff --git a/backend/internal/automation/runtime_archive.go b/backend/internal/automation/runtime_archive.go
index 012adac4..078b1107 100644
--- a/backend/internal/automation/runtime_archive.go
+++ b/backend/internal/automation/runtime_archive.go
@@ -35,21 +35,37 @@ func writeRuntimeManifest(path, nodeVersion, playwrightVersion, runtimeVersion,
}
func writeRunnerScript(path string) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ runnerDir := filepath.Dir(path)
+ if err := os.MkdirAll(runnerDir, 0o755); err != nil {
return err
}
- return os.WriteFile(path, runnerScriptContent, 0o755)
+ for name, content := range runnerAssetFiles {
+ mode := os.FileMode(0o644)
+ if name == runnerScriptFileName {
+ mode = 0o755
+ }
+ if err := os.WriteFile(filepath.Join(runnerDir, name), content, mode); err != nil {
+ return err
+ }
+ }
+ return nil
}
func syncRunnerScript(path string) error {
- current, err := os.ReadFile(path)
- if err == nil && string(current) == string(runnerScriptContent) {
- return nil
+ runnerDir := filepath.Dir(path)
+ for name, content := range runnerAssetFiles {
+ current, err := os.ReadFile(filepath.Join(runnerDir, name))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return writeRunnerScript(path)
+ }
+ return err
+ }
+ if string(current) != string(content) {
+ return writeRunnerScript(path)
+ }
}
- if err != nil && !os.IsNotExist(err) {
- return err
- }
- return writeRunnerScript(path)
+ return nil
}
func extractArchive(archivePath, destDir, format, stripPrefix string) error {
diff --git a/backend/internal/automation/runtime_manager_helpers_test.go b/backend/internal/automation/runtime_manager_helpers_test.go
new file mode 100644
index 00000000..8ac77db5
--- /dev/null
+++ b/backend/internal/automation/runtime_manager_helpers_test.go
@@ -0,0 +1,119 @@
+package automation
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func buildTestNodeZip(t *testing.T) ([]byte, string) {
+ t.Helper()
+
+ var buf bytes.Buffer
+ writer := zip.NewWriter(&buf)
+
+ header := &zip.FileHeader{
+ Name: "node-v22.15.1-win-x64/node.exe",
+ Method: zip.Deflate,
+ }
+ fileWriter, err := writer.CreateHeader(header)
+ if err != nil {
+ t.Fatalf("create node zip header failed: %v", err)
+ }
+ if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil {
+ t.Fatalf("write node zip failed: %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("close node zip failed: %v", err)
+ }
+
+ hash := sha256.Sum256(buf.Bytes())
+ return buf.Bytes(), hex.EncodeToString(hash[:])
+}
+
+func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) {
+ t.Helper()
+
+ var buf bytes.Buffer
+ gzWriter := gzip.NewWriter(&buf)
+ tarWriter := tar.NewWriter(gzWriter)
+
+ payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`)
+ header := &tar.Header{
+ Name: "package/package.json",
+ Mode: 0o644,
+ Size: int64(len(payload)),
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatalf("write playwright header failed: %v", err)
+ }
+ if _, err := tarWriter.Write(payload); err != nil {
+ t.Fatalf("write playwright payload failed: %v", err)
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatalf("close playwright tar failed: %v", err)
+ }
+ if err := gzWriter.Close(); err != nil {
+ t.Fatalf("close playwright gzip failed: %v", err)
+ }
+
+ hash := sha1.Sum(buf.Bytes())
+ return buf.Bytes(), hex.EncodeToString(hash[:])
+}
+
+func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) {
+ t.Helper()
+
+ var buf bytes.Buffer
+ gzWriter := gzip.NewWriter(&buf)
+ tarWriter := tar.NewWriter(gzWriter)
+
+ files := map[string][]byte{
+ "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`),
+ "package/index.js": []byte("exports.chromium = {};"),
+ }
+
+ for name, payload := range files {
+ header := &tar.Header{
+ Name: name,
+ Mode: 0o644,
+ Size: int64(len(payload)),
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatalf("write playable playwright header failed: %v", err)
+ }
+ if _, err := tarWriter.Write(payload); err != nil {
+ t.Fatalf("write playable playwright payload failed: %v", err)
+ }
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatalf("close playable playwright tar failed: %v", err)
+ }
+ if err := gzWriter.Close(); err != nil {
+ t.Fatalf("close playable playwright gzip failed: %v", err)
+ }
+
+ hash := sha1.Sum(buf.Bytes())
+ return buf.Bytes(), hex.EncodeToString(hash[:])
+}
+
+func writeBrokenPlaywrightModule(runtimeDir, version string) error {
+ moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
+ if err := os.MkdirAll(moduleDir, 0o755); err != nil {
+ return err
+ }
+
+ packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`)
+ if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil {
+ return err
+ }
+
+ return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644)
+}
diff --git a/backend/internal/automation/runtime_manager_test.go b/backend/internal/automation/runtime_manager_test.go
index 229a1430..dffb7e76 100644
--- a/backend/internal/automation/runtime_manager_test.go
+++ b/backend/internal/automation/runtime_manager_test.go
@@ -1,14 +1,7 @@
package automation
import (
- "archive/tar"
- "archive/zip"
- "bytes"
- "compress/gzip"
"context"
- "crypto/sha1"
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -446,108 +439,3 @@ func TestEnsureInstalledRefreshesExistingRunnerScript(t *testing.T) {
t.Fatalf("expected runner script to be refreshed")
}
}
-
-func buildTestNodeZip(t *testing.T) ([]byte, string) {
- t.Helper()
-
- var buf bytes.Buffer
- writer := zip.NewWriter(&buf)
-
- header := &zip.FileHeader{
- Name: "node-v22.15.1-win-x64/node.exe",
- Method: zip.Deflate,
- }
- fileWriter, err := writer.CreateHeader(header)
- if err != nil {
- t.Fatalf("create node zip header failed: %v", err)
- }
- if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil {
- t.Fatalf("write node zip failed: %v", err)
- }
- if err := writer.Close(); err != nil {
- t.Fatalf("close node zip failed: %v", err)
- }
-
- hash := sha256.Sum256(buf.Bytes())
- return buf.Bytes(), hex.EncodeToString(hash[:])
-}
-
-func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) {
- t.Helper()
-
- var buf bytes.Buffer
- gzWriter := gzip.NewWriter(&buf)
- tarWriter := tar.NewWriter(gzWriter)
-
- payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`)
- header := &tar.Header{
- Name: "package/package.json",
- Mode: 0o644,
- Size: int64(len(payload)),
- }
- if err := tarWriter.WriteHeader(header); err != nil {
- t.Fatalf("write playwright header failed: %v", err)
- }
- if _, err := tarWriter.Write(payload); err != nil {
- t.Fatalf("write playwright payload failed: %v", err)
- }
- if err := tarWriter.Close(); err != nil {
- t.Fatalf("close playwright tar failed: %v", err)
- }
- if err := gzWriter.Close(); err != nil {
- t.Fatalf("close playwright gzip failed: %v", err)
- }
-
- hash := sha1.Sum(buf.Bytes())
- return buf.Bytes(), hex.EncodeToString(hash[:])
-}
-
-func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) {
- t.Helper()
-
- var buf bytes.Buffer
- gzWriter := gzip.NewWriter(&buf)
- tarWriter := tar.NewWriter(gzWriter)
-
- files := map[string][]byte{
- "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`),
- "package/index.js": []byte("exports.chromium = {};"),
- }
-
- for name, payload := range files {
- header := &tar.Header{
- Name: name,
- Mode: 0o644,
- Size: int64(len(payload)),
- }
- if err := tarWriter.WriteHeader(header); err != nil {
- t.Fatalf("write playable playwright header failed: %v", err)
- }
- if _, err := tarWriter.Write(payload); err != nil {
- t.Fatalf("write playable playwright payload failed: %v", err)
- }
- }
- if err := tarWriter.Close(); err != nil {
- t.Fatalf("close playable playwright tar failed: %v", err)
- }
- if err := gzWriter.Close(); err != nil {
- t.Fatalf("close playable playwright gzip failed: %v", err)
- }
-
- hash := sha1.Sum(buf.Bytes())
- return buf.Bytes(), hex.EncodeToString(hash[:])
-}
-
-func writeBrokenPlaywrightModule(runtimeDir, version string) error {
- moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
- if err := os.MkdirAll(moduleDir, 0o755); err != nil {
- return err
- }
-
- packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`)
- if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil {
- return err
- }
-
- return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644)
-}
diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go
index ae655fdd..f4564981 100644
--- a/backend/internal/automation/script_run_store.go
+++ b/backend/internal/automation/script_run_store.go
@@ -29,6 +29,7 @@ type ScriptRunRecord struct {
type ScriptRunRequest struct {
ScriptID string `json:"scriptId"`
SelectorText string `json:"selectorText"`
+ TargetMode string `json:"targetMode,omitempty"`
TargetInput any `json:"targetInput,omitempty"`
ParamsText string `json:"paramsText"`
UseScriptSelector bool `json:"useScriptSelector"`
diff --git a/backend/internal/automation/task_runner_launch_test.go b/backend/internal/automation/task_runner_launch_test.go
new file mode 100644
index 00000000..eff8d369
--- /dev/null
+++ b/backend/internal/automation/task_runner_launch_test.go
@@ -0,0 +1,383 @@
+package automation
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "ant-chrome/backend/internal/config"
+)
+
+func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+ if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ type launchRequestPayload struct {
+ ProxyID string `json:"proxyId"`
+ ProxyConfig string `json:"proxyConfig"`
+ SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"`
+ }
+
+ receivedBody := launchRequestPayload{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Fatalf("unexpected method: %s", r.Method)
+ }
+ if r.URL.Path != "/api/launch" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&receivedBody); err != nil {
+ t.Fatalf("decode launch request body failed: %v", err)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs")
+ scriptSource := `module.exports.run = async ({ launch }) => {
+ await launch({
+ proxyId: 'proxy-picked',
+ proxyConfig: 'socks5://127.0.0.1:1080',
+ skipDefaultStartUrls: true,
+ })
+
+ return {
+ ok: true,
+ summary: '脚本执行成功',
+ }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:launch-proxy",
+ ScriptPath: scriptPath,
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+ if receivedBody.ProxyID != "proxy-picked" {
+ t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody)
+ }
+ if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" {
+ t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody)
+ }
+ if !receivedBody.SkipDefaultStartURLs {
+ t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody)
+ }
+}
+
+func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Fatalf("unexpected method: %s", r.Method)
+ }
+ if r.URL.Path != "/api/launch" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script",
+ "debugPort": 0,
+ "debugReady": false,
+ "cdpUrl": "http://127.0.0.1:0",
+ })
+ }))
+ defer server.Close()
+
+ if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-fallback.cjs")
+ scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
+ const session = await launch({ selector })
+ const connection = await connect(session)
+
+ return {
+ ok: true,
+ summary: '脚本已通过 Launch 地址回退连接',
+ connectedEndpoint: connection.session.cdpUrl,
+ profileId: session.profileId,
+ }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:fallback",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+ if result.Summary != "脚本已通过 Launch 地址回退连接" {
+ t.Fatalf("unexpected summary: %s", result.Summary)
+ }
+ if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) {
+ t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText)
+ }
+}
+
+func TestRunScriptTaskClosesBrowserConnections(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+ if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-script-close",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-close.cjs")
+ scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
+ const session = await launch({ selector })
+ const connection = await connect(session)
+
+ return {
+ ok: true,
+ summary: '脚本执行成功',
+ connectedEndpoint: connection.session.cdpUrl,
+ }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{
+ TaskKey: "script:close",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+}
+
+func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+ if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "ok": true,
+ "profileId": "profile-timeout",
+ "debugPort": 9333,
+ "cdpUrl": "http://127.0.0.1:9333",
+ })
+ }))
+ defer server.Close()
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs")
+ scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
+ const session = await launch({ selector })
+ const connection = await connect(session, { timeoutMs: 47000 })
+
+ return {
+ ok: true,
+ summary: '脚本执行成功',
+ connectedEndpoint: connection.session.cdpUrl,
+ }
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:connect-timeout",
+ ScriptPath: scriptPath,
+ Selector: map[string]any{"code": "DEMO_READY"},
+ LaunchBaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("RunScriptTask returned error: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected script task to succeed, got %+v", result)
+ }
+}
+
+func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) {
+ nodeExecPath := lookupNodeExecutable(t)
+
+ cfg := config.DefaultConfig()
+ cfg.Automation.Enabled = true
+ cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
+ cfg.Automation.SystemNodePath = nodeExecPath
+ cfg.Automation.NodeVersion = "test-node"
+ cfg.Automation.PlaywrightCoreVersion = "1.59.0"
+ cfg.Automation.RuntimeVersion = "test-runtime"
+
+ manager := NewManager(t.TempDir(), cfg, nil, Options{})
+
+ state := manager.CurrentState()
+ if err := writeRunnerScript(state.RunnerPath); err != nil {
+ t.Fatalf("write runner script failed: %v", err)
+ }
+ if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
+ t.Fatalf("write mock playwright module failed: %v", err)
+ }
+
+ scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
+ if err := os.MkdirAll(scriptDir, 0o755); err != nil {
+ t.Fatalf("create script dir failed: %v", err)
+ }
+ scriptPath := filepath.Join(scriptDir, "script-timeout.cjs")
+ scriptSource := `module.exports.run = async () => {
+ await new Promise(() => setInterval(() => {}, 1000))
+}`
+ if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
+ t.Fatalf("write script failed: %v", err)
+ }
+
+ startedAt := time.Now()
+ _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
+ TaskKey: "script:timeout",
+ ScriptPath: scriptPath,
+ LaunchBaseURL: "http://127.0.0.1",
+ Timeout: 150 * time.Millisecond,
+ })
+ elapsed := time.Since(startedAt)
+ if err == nil {
+ t.Fatalf("expected RunScriptTask to fail on timeout")
+ }
+ if !strings.Contains(err.Error(), "超时") {
+ t.Fatalf("expected timeout error, got %v", err)
+ }
+ if elapsed > 3*time.Second {
+ t.Fatalf("expected timeout to terminate quickly, took %s", elapsed)
+ }
+
+ manager.mu.Lock()
+ activeTaskCount := len(manager.activeTasks)
+ profileTaskCount := len(manager.profileTask)
+ manager.mu.Unlock()
+ if activeTaskCount != 0 || profileTaskCount != 0 {
+ t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount)
+ }
+}
diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go
index 2bfd2ccc..87da3d79 100644
--- a/backend/internal/automation/task_runner_test.go
+++ b/backend/internal/automation/task_runner_test.go
@@ -7,11 +7,9 @@ import (
"net/http"
"net/http/httptest"
"os"
- "os/exec"
"path/filepath"
"strings"
"testing"
- "time"
"ant-chrome/backend/internal/config"
)
@@ -474,575 +472,3 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) {
t.Fatalf("expected proxy launch params to be empty, got %+v", receivedBody)
}
}
-
-func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) {
- nodeExecPath := lookupNodeExecutable(t)
-
- cfg := config.DefaultConfig()
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodeExecPath
- cfg.Automation.NodeVersion = "test-node"
- cfg.Automation.PlaywrightCoreVersion = "1.59.0"
- cfg.Automation.RuntimeVersion = "test-runtime"
-
- manager := NewManager(t.TempDir(), cfg, nil, Options{})
-
- state := manager.CurrentState()
- if err := writeRunnerScript(state.RunnerPath); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
- if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
- t.Fatalf("write mock playwright module failed: %v", err)
- }
-
- type launchRequestPayload struct {
- ProxyID string `json:"proxyId"`
- ProxyConfig string `json:"proxyConfig"`
- SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"`
- }
-
- receivedBody := launchRequestPayload{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- t.Fatalf("unexpected method: %s", r.Method)
- }
- if r.URL.Path != "/api/launch" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- decoder := json.NewDecoder(r.Body)
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(&receivedBody); err != nil {
- t.Fatalf("decode launch request body failed: %v", err)
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "profileId": "profile-script",
- "debugPort": 9333,
- "cdpUrl": "http://127.0.0.1:9333",
- })
- }))
- defer server.Close()
-
- scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
- if err := os.MkdirAll(scriptDir, 0o755); err != nil {
- t.Fatalf("create script dir failed: %v", err)
- }
- scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs")
- scriptSource := `module.exports.run = async ({ launch }) => {
- await launch({
- proxyId: 'proxy-picked',
- proxyConfig: 'socks5://127.0.0.1:1080',
- skipDefaultStartUrls: true,
- })
-
- return {
- ok: true,
- summary: '脚本执行成功',
- }
-}`
- if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
- t.Fatalf("write script failed: %v", err)
- }
-
- result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
- TaskKey: "script:launch-proxy",
- ScriptPath: scriptPath,
- LaunchBaseURL: server.URL,
- })
- if err != nil {
- t.Fatalf("RunScriptTask returned error: %v", err)
- }
-
- if !result.OK {
- t.Fatalf("expected script task to succeed, got %+v", result)
- }
- if receivedBody.ProxyID != "proxy-picked" {
- t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody)
- }
- if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" {
- t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody)
- }
- if !receivedBody.SkipDefaultStartURLs {
- t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody)
- }
-}
-
-func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) {
- nodeExecPath := lookupNodeExecutable(t)
-
- cfg := config.DefaultConfig()
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodeExecPath
- cfg.Automation.NodeVersion = "test-node"
- cfg.Automation.PlaywrightCoreVersion = "1.59.0"
- cfg.Automation.RuntimeVersion = "test-runtime"
-
- manager := NewManager(t.TempDir(), cfg, nil, Options{})
-
- state := manager.CurrentState()
- if err := writeRunnerScript(state.RunnerPath); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- t.Fatalf("unexpected method: %s", r.Method)
- }
- if r.URL.Path != "/api/launch" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "profileId": "profile-script",
- "debugPort": 0,
- "debugReady": false,
- "cdpUrl": "http://127.0.0.1:0",
- })
- }))
- defer server.Close()
-
- if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil {
- t.Fatalf("write mock playwright module failed: %v", err)
- }
-
- scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
- if err := os.MkdirAll(scriptDir, 0o755); err != nil {
- t.Fatalf("create script dir failed: %v", err)
- }
- scriptPath := filepath.Join(scriptDir, "script-fallback.cjs")
- scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
- const session = await launch({ selector })
- const connection = await connect(session)
-
- return {
- ok: true,
- summary: '脚本已通过 Launch 地址回退连接',
- connectedEndpoint: connection.session.cdpUrl,
- profileId: session.profileId,
- }
-}`
- if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
- t.Fatalf("write script failed: %v", err)
- }
-
- result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
- TaskKey: "script:fallback",
- ScriptPath: scriptPath,
- Selector: map[string]any{"code": "DEMO_READY"},
- LaunchBaseURL: server.URL,
- })
- if err != nil {
- t.Fatalf("RunScriptTask returned error: %v", err)
- }
-
- if !result.OK {
- t.Fatalf("expected script task to succeed, got %+v", result)
- }
- if result.Summary != "脚本已通过 Launch 地址回退连接" {
- t.Fatalf("unexpected summary: %s", result.Summary)
- }
- if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) {
- t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText)
- }
-}
-
-func TestRunScriptTaskClosesBrowserConnections(t *testing.T) {
- nodeExecPath := lookupNodeExecutable(t)
-
- cfg := config.DefaultConfig()
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodeExecPath
- cfg.Automation.NodeVersion = "test-node"
- cfg.Automation.PlaywrightCoreVersion = "1.59.0"
- cfg.Automation.RuntimeVersion = "test-runtime"
-
- manager := NewManager(t.TempDir(), cfg, nil, Options{})
-
- state := manager.CurrentState()
- if err := writeRunnerScript(state.RunnerPath); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
- if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil {
- t.Fatalf("write mock playwright module failed: %v", err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "profileId": "profile-script-close",
- "debugPort": 9333,
- "cdpUrl": "http://127.0.0.1:9333",
- })
- }))
- defer server.Close()
-
- scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
- if err := os.MkdirAll(scriptDir, 0o755); err != nil {
- t.Fatalf("create script dir failed: %v", err)
- }
- scriptPath := filepath.Join(scriptDir, "script-close.cjs")
- scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
- const session = await launch({ selector })
- const connection = await connect(session)
-
- return {
- ok: true,
- summary: '脚本执行成功',
- connectedEndpoint: connection.session.cdpUrl,
- }
-}`
- if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
- t.Fatalf("write script failed: %v", err)
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
-
- result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{
- TaskKey: "script:close",
- ScriptPath: scriptPath,
- Selector: map[string]any{"code": "DEMO_READY"},
- LaunchBaseURL: server.URL,
- })
- if err != nil {
- t.Fatalf("RunScriptTask returned error: %v", err)
- }
- if !result.OK {
- t.Fatalf("expected script task to succeed, got %+v", result)
- }
-}
-
-func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) {
- nodeExecPath := lookupNodeExecutable(t)
-
- cfg := config.DefaultConfig()
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodeExecPath
- cfg.Automation.NodeVersion = "test-node"
- cfg.Automation.PlaywrightCoreVersion = "1.59.0"
- cfg.Automation.RuntimeVersion = "test-runtime"
-
- manager := NewManager(t.TempDir(), cfg, nil, Options{})
-
- state := manager.CurrentState()
- if err := writeRunnerScript(state.RunnerPath); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
- if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil {
- t.Fatalf("write mock playwright module failed: %v", err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "profileId": "profile-timeout",
- "debugPort": 9333,
- "cdpUrl": "http://127.0.0.1:9333",
- })
- }))
- defer server.Close()
-
- scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
- if err := os.MkdirAll(scriptDir, 0o755); err != nil {
- t.Fatalf("create script dir failed: %v", err)
- }
- scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs")
- scriptSource := `module.exports.run = async ({ launch, connect, selector }) => {
- const session = await launch({ selector })
- const connection = await connect(session, { timeoutMs: 47000 })
-
- return {
- ok: true,
- summary: '脚本执行成功',
- connectedEndpoint: connection.session.cdpUrl,
- }
-}`
- if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
- t.Fatalf("write script failed: %v", err)
- }
-
- result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
- TaskKey: "script:connect-timeout",
- ScriptPath: scriptPath,
- Selector: map[string]any{"code": "DEMO_READY"},
- LaunchBaseURL: server.URL,
- })
- if err != nil {
- t.Fatalf("RunScriptTask returned error: %v", err)
- }
- if !result.OK {
- t.Fatalf("expected script task to succeed, got %+v", result)
- }
-}
-
-func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) {
- nodeExecPath := lookupNodeExecutable(t)
-
- cfg := config.DefaultConfig()
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodeExecPath
- cfg.Automation.NodeVersion = "test-node"
- cfg.Automation.PlaywrightCoreVersion = "1.59.0"
- cfg.Automation.RuntimeVersion = "test-runtime"
-
- manager := NewManager(t.TempDir(), cfg, nil, Options{})
-
- state := manager.CurrentState()
- if err := writeRunnerScript(state.RunnerPath); err != nil {
- t.Fatalf("write runner script failed: %v", err)
- }
- if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil {
- t.Fatalf("write mock playwright module failed: %v", err)
- }
-
- scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts")
- if err := os.MkdirAll(scriptDir, 0o755); err != nil {
- t.Fatalf("create script dir failed: %v", err)
- }
- scriptPath := filepath.Join(scriptDir, "script-timeout.cjs")
- scriptSource := `module.exports.run = async () => {
- await new Promise(() => setInterval(() => {}, 1000))
-}`
- if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil {
- t.Fatalf("write script failed: %v", err)
- }
-
- startedAt := time.Now()
- _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{
- TaskKey: "script:timeout",
- ScriptPath: scriptPath,
- LaunchBaseURL: "http://127.0.0.1",
- Timeout: 150 * time.Millisecond,
- })
- elapsed := time.Since(startedAt)
- if err == nil {
- t.Fatalf("expected RunScriptTask to fail on timeout")
- }
- if !strings.Contains(err.Error(), "超时") {
- t.Fatalf("expected timeout error, got %v", err)
- }
- if elapsed > 3*time.Second {
- t.Fatalf("expected timeout to terminate quickly, took %s", elapsed)
- }
-
- manager.mu.Lock()
- activeTaskCount := len(manager.activeTasks)
- profileTaskCount := len(manager.profileTask)
- manager.mu.Unlock()
- if activeTaskCount != 0 || profileTaskCount != 0 {
- t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount)
- }
-}
-
-func lookupNodeExecutable(t *testing.T) string {
- t.Helper()
-
- nodePath, err := exec.LookPath("node")
- if err != nil {
- t.Skipf("node is not available: %v", err)
- }
-
- cmd := exec.Command(nodePath, "-p", "process.execPath")
- output, err := cmd.Output()
- if err != nil {
- return nodePath
- }
-
- resolved := strings.TrimSpace(string(output))
- if resolved == "" {
- return nodePath
- }
- return resolved
-}
-
-func writeMockPlaywrightModule(runtimeDir, version string) error {
- return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "")
-}
-
-func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error {
- return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false)
-}
-
-func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error {
- return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true)
-}
-
-func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error {
- moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
- if err := os.MkdirAll(moduleDir, 0o755); err != nil {
- return err
- }
-
- packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version)
- if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil {
- return err
- }
-
- indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d;
-
-const context = {
- async grantPermissions() {},
- async newPage() {
- return {
- async goto() {},
- async bringToFront() {},
- async waitForLoadState() {},
- async waitForTimeout() {},
- async close() {},
- isClosed() {
- return false;
- },
- async title() {
- return 'Mock Page Title';
- },
- url() {
- return 'about:blank';
- },
- };
- },
- pages() {
- return [];
- },
-};
-
-exports.chromium = {
- async connectOverCDP(endpoint, options = {}) {
- if (options.timeout !== expectedConnectTimeout) {
- throw new Error('unexpected connect timeout: ' + String(options.timeout));
- }
- return {
- contexts() {
- return [context];
- },
- async close() {},
- };
- },
-};
-`, expectedConnectTimeout)
- return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644)
-}
-
-func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error {
- moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
- if err := os.MkdirAll(moduleDir, 0o755); err != nil {
- return err
- }
-
- packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version)
- if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil {
- return err
- }
-
- expectedEndpointJSON, err := json.Marshal(expectedEndpoint)
- if err != nil {
- return err
- }
- persistentConnectionJSON, err := json.Marshal(persistentConnection)
- if err != nil {
- return err
- }
-
- indexJS := fmt.Sprintf(`const fs = require('fs');
-
-const expectedEndpoint = %s;
-const persistentConnection = %s;
-
-function createPage() {
- let currentURL = 'about:blank';
- return {
- async goto(url) {
- currentURL = url;
- },
- async bringToFront() {},
- async waitForLoadState() {},
- async waitForTimeout() {},
- async screenshot(options) {
- fs.writeFileSync(options.path, 'mock-screenshot');
- },
- async evaluate(fn, arg) {
- const previousFetch = global.fetch;
- global.fetch = async (url, init = {}) => {
- return {
- ok: String(init.method || 'GET').toUpperCase() !== 'DELETE',
- status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200,
- statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK',
- url: String(url),
- headers: {
- forEach(callback) {
- callback('application/json', 'content-type');
- },
- },
- async text() {
- return JSON.stringify({
- ok: true,
- url: String(url),
- method: String(init.method || 'GET').toUpperCase(),
- credentials: init.credentials || '',
- headers: init.headers || {},
- body: init.body || '',
- });
- },
- };
- };
- try {
- return await fn(arg);
- } finally {
- global.fetch = previousFetch;
- }
- },
- async title() {
- return 'Mock Page Title';
- },
- url() {
- return currentURL;
- },
- isClosed() {
- return false;
- },
- async close() {},
- };
-}
-
-const context = {
- async grantPermissions() {},
- async newPage() {
- return createPage();
- },
- pages() {
- return [];
- },
-};
-
-exports.chromium = {
- async connectOverCDP(endpoint) {
- if (String(endpoint).includes(':0')) {
- throw new Error('invalid cdp endpoint');
- }
- if (expectedEndpoint && endpoint !== expectedEndpoint) {
- throw new Error('unexpected cdp endpoint: ' + endpoint);
- }
- const hold = persistentConnection ? setInterval(() => {}, 1000) : null;
- return {
- contexts() {
- return [context];
- },
- async close() {
- if (hold) {
- clearInterval(hold);
- }
- },
- };
- },
-};
-`, string(expectedEndpointJSON), string(persistentConnectionJSON))
- return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644)
-}
diff --git a/backend/internal/automation/task_runner_test_helpers_test.go b/backend/internal/automation/task_runner_test_helpers_test.go
new file mode 100644
index 00000000..28cb6464
--- /dev/null
+++ b/backend/internal/automation/task_runner_test_helpers_test.go
@@ -0,0 +1,215 @@
+package automation
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func lookupNodeExecutable(t *testing.T) string {
+ t.Helper()
+
+ nodePath, err := exec.LookPath("node")
+ if err != nil {
+ t.Skipf("node is not available: %v", err)
+ }
+
+ cmd := exec.Command(nodePath, "-p", "process.execPath")
+ output, err := cmd.Output()
+ if err != nil {
+ return nodePath
+ }
+
+ resolved := strings.TrimSpace(string(output))
+ if resolved == "" {
+ return nodePath
+ }
+ return resolved
+}
+
+func writeMockPlaywrightModule(runtimeDir, version string) error {
+ return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "")
+}
+
+func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error {
+ return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false)
+}
+
+func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error {
+ return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true)
+}
+
+func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error {
+ moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
+ if err := os.MkdirAll(moduleDir, 0o755); err != nil {
+ return err
+ }
+
+ packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version)
+ if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil {
+ return err
+ }
+
+ indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d;
+
+const context = {
+ async grantPermissions() {},
+ async newPage() {
+ return {
+ async goto() {},
+ async bringToFront() {},
+ async waitForLoadState() {},
+ async waitForTimeout() {},
+ async close() {},
+ isClosed() {
+ return false;
+ },
+ async title() {
+ return 'Mock Page Title';
+ },
+ url() {
+ return 'about:blank';
+ },
+ };
+ },
+ pages() {
+ return [];
+ },
+};
+
+exports.chromium = {
+ async connectOverCDP(endpoint, options = {}) {
+ if (options.timeout !== expectedConnectTimeout) {
+ throw new Error('unexpected connect timeout: ' + String(options.timeout));
+ }
+ return {
+ contexts() {
+ return [context];
+ },
+ async close() {},
+ };
+ },
+};
+`, expectedConnectTimeout)
+ return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644)
+}
+
+func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error {
+ moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core")
+ if err := os.MkdirAll(moduleDir, 0o755); err != nil {
+ return err
+ }
+
+ packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version)
+ if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil {
+ return err
+ }
+
+ expectedEndpointJSON, err := json.Marshal(expectedEndpoint)
+ if err != nil {
+ return err
+ }
+ persistentConnectionJSON, err := json.Marshal(persistentConnection)
+ if err != nil {
+ return err
+ }
+
+ indexJS := fmt.Sprintf(`const fs = require('fs');
+
+const expectedEndpoint = %s;
+const persistentConnection = %s;
+
+function createPage() {
+ let currentURL = 'about:blank';
+ return {
+ async goto(url) {
+ currentURL = url;
+ },
+ async bringToFront() {},
+ async waitForLoadState() {},
+ async waitForTimeout() {},
+ async screenshot(options) {
+ fs.writeFileSync(options.path, 'mock-screenshot');
+ },
+ async evaluate(fn, arg) {
+ const previousFetch = global.fetch;
+ global.fetch = async (url, init = {}) => {
+ return {
+ ok: String(init.method || 'GET').toUpperCase() !== 'DELETE',
+ status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200,
+ statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK',
+ url: String(url),
+ headers: {
+ forEach(callback) {
+ callback('application/json', 'content-type');
+ },
+ },
+ async text() {
+ return JSON.stringify({
+ ok: true,
+ url: String(url),
+ method: String(init.method || 'GET').toUpperCase(),
+ credentials: init.credentials || '',
+ headers: init.headers || {},
+ body: init.body || '',
+ });
+ },
+ };
+ };
+ try {
+ return await fn(arg);
+ } finally {
+ global.fetch = previousFetch;
+ }
+ },
+ async title() {
+ return 'Mock Page Title';
+ },
+ url() {
+ return currentURL;
+ },
+ isClosed() {
+ return false;
+ },
+ async close() {},
+ };
+}
+
+const context = {
+ async grantPermissions() {},
+ async newPage() {
+ return createPage();
+ },
+ pages() {
+ return [];
+ },
+};
+
+exports.chromium = {
+ async connectOverCDP(endpoint) {
+ if (String(endpoint).includes(':0')) {
+ throw new Error('invalid cdp endpoint');
+ }
+ if (expectedEndpoint && endpoint !== expectedEndpoint) {
+ throw new Error('unexpected cdp endpoint: ' + endpoint);
+ }
+ const hold = persistentConnection ? setInterval(() => {}, 1000) : null;
+ return {
+ contexts() {
+ return [context];
+ },
+ async close() {
+ if (hold) {
+ clearInterval(hold);
+ }
+ },
+ };
+ },
+};
+`, string(expectedEndpointJSON), string(persistentConnectionJSON))
+ return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644)
+}
diff --git a/backend/internal/browser/dashboard.go b/backend/internal/browser/dashboard.go
new file mode 100644
index 00000000..6ef9b33e
--- /dev/null
+++ b/backend/internal/browser/dashboard.go
@@ -0,0 +1,43 @@
+package browser
+
+import "ant-chrome/backend/internal/config"
+
+const DefaultMaxProfileLimit = 20
+
+type DashboardStats struct {
+ TotalInstances int
+ RunningInstances int
+ ProxyCount int
+ CoreCount int
+ MaxProfileLimit int
+}
+
+func BuildDashboardStats(profiles []Profile, cfg *config.Config) DashboardStats {
+ stats := DashboardStats{
+ TotalInstances: len(profiles),
+ MaxProfileLimit: DefaultMaxProfileLimit,
+ }
+ for _, profile := range profiles {
+ if profile.Running {
+ stats.RunningInstances++
+ }
+ }
+ if cfg != nil {
+ stats.ProxyCount = len(cfg.Browser.Proxies)
+ stats.CoreCount = len(cfg.Browser.Cores)
+ if cfg.App.MaxProfileLimit > 0 {
+ stats.MaxProfileLimit = cfg.App.MaxProfileLimit
+ }
+ }
+ return stats
+}
+
+func RunningProfiles(profiles []Profile) []Profile {
+ result := make([]Profile, 0)
+ for _, profile := range profiles {
+ if profile.Running {
+ result = append(result, profile)
+ }
+ }
+ return result
+}
diff --git a/backend/internal/browser/dashboard_test.go b/backend/internal/browser/dashboard_test.go
new file mode 100644
index 00000000..b0f4be9f
--- /dev/null
+++ b/backend/internal/browser/dashboard_test.go
@@ -0,0 +1,43 @@
+package browser
+
+import (
+ "ant-chrome/backend/internal/config"
+ "testing"
+)
+
+func TestBuildDashboardStats(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Browser.Proxies = []config.BrowserProxy{{ProxyId: "p1"}, {ProxyId: "p2"}}
+ cfg.Browser.Cores = []config.BrowserCore{{CoreId: "c1"}}
+ cfg.App.MaxProfileLimit = 7
+
+ stats := BuildDashboardStats([]Profile{
+ {ProfileId: "a", Running: true},
+ {ProfileId: "b", Running: false},
+ {ProfileId: "c", Running: true},
+ }, cfg)
+
+ if stats.TotalInstances != 3 || stats.RunningInstances != 2 {
+ t.Fatalf("instance stats = %#v", stats)
+ }
+ if stats.ProxyCount != 2 || stats.CoreCount != 1 || stats.MaxProfileLimit != 7 {
+ t.Fatalf("config stats = %#v", stats)
+ }
+}
+
+func TestBuildDashboardStatsDefaultsWithoutConfig(t *testing.T) {
+ stats := BuildDashboardStats(nil, nil)
+ if stats.MaxProfileLimit != DefaultMaxProfileLimit {
+ t.Fatalf("max profile limit = %d", stats.MaxProfileLimit)
+ }
+}
+
+func TestRunningProfiles(t *testing.T) {
+ profiles := RunningProfiles([]Profile{
+ {ProfileId: "a", Running: true},
+ {ProfileId: "b"},
+ })
+ if len(profiles) != 1 || profiles[0].ProfileId != "a" {
+ t.Fatalf("profiles = %#v", profiles)
+ }
+}
diff --git a/backend/internal/browser/proxy_queries.go b/backend/internal/browser/proxy_queries.go
new file mode 100644
index 00000000..5073bc69
--- /dev/null
+++ b/backend/internal/browser/proxy_queries.go
@@ -0,0 +1,44 @@
+package browser
+
+func ListProxiesWithFallback(proxyDAO ProxyDAO, fallback []Proxy) []Proxy {
+ if proxyDAO != nil {
+ if list, err := proxyDAO.List(); err == nil {
+ return list
+ }
+ }
+ return append([]Proxy{}, fallback...)
+}
+
+func ListProxyGroups(proxyDAO ProxyDAO) []string {
+ if proxyDAO != nil {
+ if groups, err := proxyDAO.ListGroups(); err == nil {
+ return groups
+ }
+ }
+ return nil
+}
+
+func ListProxiesByGroupWithFallback(proxyDAO ProxyDAO, groupName string, fallback []Proxy) []Proxy {
+ if proxyDAO != nil {
+ if list, err := proxyDAO.ListByGroup(groupName); err == nil {
+ return list
+ }
+ }
+
+ var result []Proxy
+ for _, item := range fallback {
+ if item.GroupName == groupName {
+ result = append(result, item)
+ }
+ }
+ return result
+}
+
+func LatestProxiesWithFallback(proxyDAO ProxyDAO, fallback []Proxy) []Proxy {
+ if proxyDAO != nil {
+ if list, err := proxyDAO.List(); err == nil && len(list) > 0 {
+ return list
+ }
+ }
+ return fallback
+}
diff --git a/backend/internal/browser/proxy_queries_test.go b/backend/internal/browser/proxy_queries_test.go
new file mode 100644
index 00000000..c09f4946
--- /dev/null
+++ b/backend/internal/browser/proxy_queries_test.go
@@ -0,0 +1,59 @@
+package browser
+
+import (
+ "errors"
+ "testing"
+)
+
+type proxyQueryTestDAO struct {
+ list []Proxy
+ groups []string
+ groupList []Proxy
+ listErr error
+ groupsErr error
+ groupListErr error
+}
+
+func (d proxyQueryTestDAO) List() ([]Proxy, error) { return d.list, d.listErr }
+func (d proxyQueryTestDAO) ListByGroup(string) ([]Proxy, error) { return d.groupList, d.groupListErr }
+func (d proxyQueryTestDAO) ListGroups() ([]string, error) { return d.groups, d.groupsErr }
+func (d proxyQueryTestDAO) Upsert(Proxy) error { return nil }
+func (d proxyQueryTestDAO) Delete(string) error { return nil }
+func (d proxyQueryTestDAO) DeleteAll() error { return nil }
+func (d proxyQueryTestDAO) UpdateSpeedResult(string, bool, int64, string) error { return nil }
+func (d proxyQueryTestDAO) UpdateIPHealthResult(string, string) error { return nil }
+
+func TestListProxiesWithFallbackUsesDAO(t *testing.T) {
+ fallback := []Proxy{{ProxyId: "fallback"}}
+ list := ListProxiesWithFallback(proxyQueryTestDAO{list: []Proxy{{ProxyId: "dao"}}}, fallback)
+ if len(list) != 1 || list[0].ProxyId != "dao" {
+ t.Fatalf("list = %#v", list)
+ }
+}
+
+func TestListProxiesWithFallbackCopiesFallback(t *testing.T) {
+ fallback := []Proxy{{ProxyId: "fallback"}}
+ list := ListProxiesWithFallback(proxyQueryTestDAO{listErr: errors.New("failed")}, fallback)
+ list[0].ProxyId = "changed"
+ if fallback[0].ProxyId != "fallback" {
+ t.Fatalf("fallback was mutated")
+ }
+}
+
+func TestListProxiesByGroupWithFallbackFiltersFallback(t *testing.T) {
+ list := ListProxiesByGroupWithFallback(nil, "group-a", []Proxy{
+ {ProxyId: "a", GroupName: "group-a"},
+ {ProxyId: "b", GroupName: "group-b"},
+ })
+ if len(list) != 1 || list[0].ProxyId != "a" {
+ t.Fatalf("list = %#v", list)
+ }
+}
+
+func TestLatestProxiesWithFallbackKeepsFallbackForEmptyDAOList(t *testing.T) {
+ fallback := []Proxy{{ProxyId: "fallback"}}
+ list := LatestProxiesWithFallback(proxyQueryTestDAO{}, fallback)
+ if len(list) != 1 || list[0].ProxyId != "fallback" {
+ t.Fatalf("list = %#v", list)
+ }
+}
diff --git a/backend/internal/fsutil/path.go b/backend/internal/fsutil/path.go
index dfb24caa..a5a7cb9e 100644
--- a/backend/internal/fsutil/path.go
+++ b/backend/internal/fsutil/path.go
@@ -25,6 +25,39 @@ func NormalizePathInput(p string) string {
return cleaned
}
+func ResolveUserDataDir(appPathResolver func(string) string, userDataRoot string, userDataDir string) (string, error) {
+ userDataDir = strings.TrimSpace(userDataDir)
+ if userDataDir == "" {
+ return "", fmt.Errorf("用户数据目录不能为空")
+ }
+ if filepath.IsAbs(userDataDir) {
+ return userDataDir, nil
+ }
+
+ root := strings.TrimSpace(userDataRoot)
+ if root == "" {
+ root = "data"
+ }
+ if appPathResolver != nil {
+ root = appPathResolver(root)
+ }
+ return filepath.Join(root, userDataDir), nil
+}
+
+func ResolveExistingPath(appPathResolver func(string) string, inputPath string, emptyMessage string) (string, error) {
+ inputPath = strings.TrimSpace(inputPath)
+ if inputPath == "" {
+ return "", fmt.Errorf(emptyMessage)
+ }
+ if filepath.IsAbs(inputPath) {
+ return inputPath, nil
+ }
+ if appPathResolver != nil {
+ return appPathResolver(inputPath), nil
+ }
+ return inputPath, nil
+}
+
// ValidateExecutable checks whether a file is runnable on the current platform.
func ValidateExecutable(path string) error {
info, err := os.Stat(path)
diff --git a/backend/internal/fsutil/path_test.go b/backend/internal/fsutil/path_test.go
index a68c9dd4..861b1e5e 100644
--- a/backend/internal/fsutil/path_test.go
+++ b/backend/internal/fsutil/path_test.go
@@ -17,6 +17,48 @@ func TestNormalizePathInputConvertsWindowsSeparators(t *testing.T) {
}
}
+func TestResolveUserDataDir(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ got, err := ResolveUserDataDir(func(path string) string {
+ return filepath.Join(root, path)
+ }, "profiles", "profile-a")
+ if err != nil {
+ t.Fatalf("ResolveUserDataDir() 返回错误: %v", err)
+ }
+ want := filepath.Join(root, "profiles", "profile-a")
+ if got != want {
+ t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want)
+ }
+}
+
+func TestResolveUserDataDirUsesDefaultRoot(t *testing.T) {
+ t.Parallel()
+
+ got, err := ResolveUserDataDir(func(path string) string { return filepath.Join("app", path) }, "", "profile-a")
+ if err != nil {
+ t.Fatalf("ResolveUserDataDir() 返回错误: %v", err)
+ }
+ want := filepath.Join("app", "data", "profile-a")
+ if got != want {
+ t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want)
+ }
+}
+
+func TestResolveExistingPathUsesResolverForRelativePath(t *testing.T) {
+ t.Parallel()
+
+ got, err := ResolveExistingPath(func(path string) string { return filepath.Join("app", path) }, "chrome/core", "不能为空")
+ if err != nil {
+ t.Fatalf("ResolveExistingPath() 返回错误: %v", err)
+ }
+ want := filepath.Join("app", "chrome/core")
+ if got != want {
+ t.Fatalf("ResolveExistingPath() = %q, want %q", got, want)
+ }
+}
+
func TestEnsureExecutableRepairsMissingExecBitsOnUnix(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/launchcode/automation_public_api.go b/backend/internal/launchcode/automation_public_api.go
index 4f9bde06..fffe73d4 100644
--- a/backend/internal/launchcode/automation_public_api.go
+++ b/backend/internal/launchcode/automation_public_api.go
@@ -1,4 +1,4 @@
-package launchcode
+package launchcode
import (
"bytes"
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"path"
- "strconv"
"strings"
"ant-chrome/backend/internal/automation"
@@ -138,15 +137,9 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http
if err != nil {
return automation.ScriptRunRequest{}, err
}
- selectorText := ""
- useScriptSelector := true
- if strings.TrimSpace(input.Code) != "" {
- encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": strings.TrimSpace(input.Code)})
- if err != nil {
- return automation.ScriptRunRequest{}, badAutomationRequest("code is invalid")
- }
- selectorText = encodedSelectorText
- useScriptSelector = false
+ targetMode, targetInput, selectorText, useScriptSelector, err := resolveAutomationPublicHookInstance(input)
+ if err != nil {
+ return automation.ScriptRunRequest{}, err
}
if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil {
return automation.ScriptRunRequest{}, err
@@ -160,6 +153,8 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http
return automation.ScriptRunRequest{
ScriptID: record.ID,
SelectorText: selectorText,
+ TargetMode: targetMode,
+ TargetInput: targetInput,
ParamsText: paramsText,
UseScriptSelector: useScriptSelector,
UseScriptParams: false,
@@ -168,9 +163,18 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http
}
type automationPublicHookRequestBody struct {
- Code string `json:"code"`
- Params map[string]interface{} `json:"params"`
- TimeoutMs int `json:"timeoutMs"`
+ Code string `json:"code"`
+ Instance *automationPublicHookInstance `json:"instance"`
+ Params map[string]interface{} `json:"params"`
+ TimeoutMs int `json:"timeoutMs"`
+}
+
+type automationPublicHookInstance struct {
+ Type string `json:"type"`
+ Selector automation.ScriptTargetSelector `json:"selector"`
+ TemplateSelector automation.ScriptTargetSelector `json:"templateSelector"`
+ CreateNameTemplate string `json:"createNameTemplate"`
+ ProfileName string `json:"profileName"`
}
func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) {
@@ -191,6 +195,61 @@ func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookReq
return input, nil
}
+func resolveAutomationPublicHookInstance(input automationPublicHookRequestBody) (string, any, string, bool, error) {
+ legacyCode := strings.TrimSpace(input.Code)
+ if input.Instance == nil {
+ if legacyCode == "" {
+ return "", nil, "", true, nil
+ }
+ encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": legacyCode})
+ if err != nil {
+ return "", nil, "", true, badAutomationRequest("code is invalid")
+ }
+ return "", nil, encodedSelectorText, false, nil
+ }
+
+ if legacyCode != "" {
+ return "", nil, "", true, badAutomationRequest("code and instance cannot be used together")
+ }
+
+ switch strings.ToLower(strings.TrimSpace(input.Instance.Type)) {
+ case "script-default":
+ return "", nil, "", true, nil
+ case "existing", "rotate":
+ selector := input.Instance.Selector
+ if automationPublicHookTargetSelectorEmpty(selector) {
+ return "", nil, "", true, badAutomationRequest("instance.selector is required")
+ }
+ return strings.ToLower(strings.TrimSpace(input.Instance.Type)), selector, "", false, nil
+ case "create":
+ targetInput := map[string]interface{}{
+ "templateSelector": input.Instance.TemplateSelector,
+ }
+ if automationPublicHookTargetSelectorEmpty(input.Instance.TemplateSelector) {
+ return "", nil, "", true, badAutomationRequest("instance.templateSelector is required")
+ }
+ if name := strings.TrimSpace(input.Instance.CreateNameTemplate); name != "" {
+ targetInput["createNameTemplate"] = name
+ } else if name := strings.TrimSpace(input.Instance.ProfileName); name != "" {
+ targetInput["profileName"] = name
+ }
+ return "create", targetInput, "", false, nil
+ case "":
+ return "", nil, "", true, badAutomationRequest("instance.type is required")
+ default:
+ return "", nil, "", true, badAutomationRequest("instance.type is unsupported")
+ }
+}
+
+func automationPublicHookTargetSelectorEmpty(selector automation.ScriptTargetSelector) bool {
+ return strings.TrimSpace(selector.Code) == "" &&
+ strings.TrimSpace(selector.ProfileID) == "" &&
+ strings.TrimSpace(selector.ProfileName) == "" &&
+ strings.TrimSpace(selector.GroupID) == "" &&
+ len(selector.Keywords) == 0 &&
+ len(selector.Tags) == 0
+}
+
func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) {
encoded, err := json.Marshal(obj)
if err != nil {
@@ -232,7 +291,7 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body
}
values := input.Params
- bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code)
+ bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", automationPublicHookInstanceCode(input))
for _, variable := range config.Variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
@@ -250,7 +309,7 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body
continue
}
- rawValue := interface{}(variable.DefaultValue)
+ rawValue := automationPublicHookVariableDefaultValue(variable, input)
if incomingValue, ok := values[name]; ok {
rawValue = incomingValue
}
@@ -280,6 +339,25 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body
return encoded, nil
}
+func automationPublicHookInstanceCode(input automationPublicHookRequestBody) string {
+ if code := strings.TrimSpace(input.Code); code != "" {
+ return code
+ }
+ if input.Instance == nil {
+ return ""
+ }
+ return strings.TrimSpace(input.Instance.Selector.Code)
+}
+
+func automationPublicHookVariableDefaultValue(variable automation.ScriptPublicAPIVariable, input automationPublicHookRequestBody) interface{} {
+ if strings.TrimSpace(variable.Name) == "code" {
+ if code := automationPublicHookInstanceCode(input); code != "" {
+ return code
+ }
+ }
+ return variable.DefaultValue
+}
+
func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} {
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
if !ok || len(defaultParams) == 0 {
@@ -406,148 +484,3 @@ func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{}
return obj, true, nil
}
-func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int {
- if requestTimeout > 0 {
- return requestTimeout
- }
-
- if r != nil {
- if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" {
- if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
- return parsed
- }
- }
- }
-
- return fallback
-}
-
-func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) {
- _ = record
- parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText)
- if run.Status != "success" {
- writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run))
- return
- }
-
- response := map[string]interface{}{
- "ok": true,
- "status": run.Status,
- "summary": run.Summary,
- "message": run.Summary,
- "data": map[string]interface{}{},
- "result": map[string]interface{}{},
- }
-
- if hasResult {
- data := compactAutomationPublicHookData(resultPayload, run)
- response["data"] = data
- response["result"] = data
- } else if parsedPayload != nil {
- data := compactAutomationPublicHookData(parsedPayload, run)
- response["data"] = data
- response["result"] = data
- }
-
- writeJSON(w, http.StatusOK, response)
-}
-
-func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} {
- response := map[string]interface{}{
- "ok": false,
- "status": run.Status,
- "summary": run.Summary,
- "message": run.Summary,
- "data": map[string]interface{}{},
- "result": map[string]interface{}{},
- }
- if strings.TrimSpace(run.Error) != "" {
- response["error"] = run.Error
- }
- return response
-}
-
-func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} {
- data := compactAutomationPublicHookResult(payload, run)
- delete(data, "ok")
- delete(data, "summary")
- return data
-}
-
-func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} {
- obj, ok := payload.(map[string]interface{})
- if !ok {
- result := map[string]interface{}{"ok": true}
- if strings.TrimSpace(run.Summary) != "" {
- result["summary"] = run.Summary
- }
- if payload != nil {
- result["result"] = payload
- }
- return result
- }
-
- if !hasAutomationPublicHookDownloadField(obj) {
- result := make(map[string]interface{}, len(obj)+1)
- result["ok"] = true
- for key, value := range obj {
- if key != "ok" && value != nil {
- result[key] = value
- }
- }
- if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" {
- result["summary"] = run.Summary
- }
- return result
- }
-
- result := map[string]interface{}{"ok": true}
-
- for _, key := range []string{
- "downloadAddress",
- "downloadPath",
- "outputPath",
- "sourceImageUrl",
- "sourceDownloadUrl",
- "screenshotPath",
- "pageScreenshotPath",
- "contentType",
- "imageWidth",
- "imageHeight",
- "status",
- "summary",
- "error",
- } {
- if value, exists := obj[key]; exists && value != nil {
- result[key] = value
- }
- }
- return result
-}
-
-func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool {
- for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} {
- if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
- return true
- }
- }
- return false
-}
-
-func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) {
- trimmed := strings.TrimSpace(raw)
- if trimmed == "" {
- return nil, nil, false
- }
-
- var payload interface{}
- if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
- return nil, nil, false
- }
-
- if obj, ok := payload.(map[string]interface{}); ok {
- result, exists := obj["result"]
- return payload, result, exists
- }
- return payload, nil, false
-}
diff --git a/backend/internal/launchcode/automation_public_api_response.go b/backend/internal/launchcode/automation_public_api_response.go
new file mode 100644
index 00000000..3fa85faa
--- /dev/null
+++ b/backend/internal/launchcode/automation_public_api_response.go
@@ -0,0 +1,125 @@
+package launchcode
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "ant-chrome/backend/internal/automation"
+)
+
+func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) {
+ _ = record
+ parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText)
+ if run.Status != "success" {
+ writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run))
+ return
+ }
+
+ response := map[string]interface{}{
+ "ok": true,
+ "status": run.Status,
+ "summary": run.Summary,
+ "message": run.Summary,
+ "data": map[string]interface{}{},
+ "result": map[string]interface{}{},
+ }
+
+ if hasResult {
+ data := compactAutomationPublicHookData(resultPayload, run)
+ response["data"] = data
+ response["result"] = data
+ } else if parsedPayload != nil {
+ data := compactAutomationPublicHookData(parsedPayload, run)
+ response["data"] = data
+ response["result"] = data
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} {
+ response := map[string]interface{}{
+ "ok": false,
+ "status": run.Status,
+ "summary": run.Summary,
+ "message": run.Summary,
+ "data": map[string]interface{}{},
+ "result": map[string]interface{}{},
+ }
+ if strings.TrimSpace(run.Error) != "" {
+ response["error"] = run.Error
+ }
+ return response
+}
+
+func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} {
+ data := compactAutomationPublicHookResult(payload, run)
+ delete(data, "ok")
+ delete(data, "summary")
+ return data
+}
+
+func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} {
+ obj, ok := payload.(map[string]interface{})
+ if !ok {
+ result := map[string]interface{}{"ok": true}
+ if strings.TrimSpace(run.Summary) != "" {
+ result["summary"] = run.Summary
+ }
+ if payload != nil {
+ result["result"] = payload
+ }
+ return result
+ }
+
+ if !hasAutomationPublicHookDownloadField(obj) {
+ result := make(map[string]interface{}, len(obj)+1)
+ result["ok"] = true
+ for key, value := range obj {
+ if key != "ok" && value != nil {
+ result[key] = value
+ }
+ }
+ if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" {
+ result["summary"] = run.Summary
+ }
+ return result
+ }
+
+ result := map[string]interface{}{"ok": true}
+ for _, key := range []string{"downloadAddress", "downloadPath", "outputPath", "sourceImageUrl", "sourceDownloadUrl", "screenshotPath", "pageScreenshotPath", "contentType", "imageWidth", "imageHeight", "status", "summary", "error"} {
+ if value, exists := obj[key]; exists && value != nil {
+ result[key] = value
+ }
+ }
+ return result
+}
+
+func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool {
+ for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} {
+ if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return nil, nil, false
+ }
+
+ var payload interface{}
+ if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
+ return nil, nil, false
+ }
+
+ if obj, ok := payload.(map[string]interface{}); ok {
+ result, exists := obj["result"]
+ return payload, result, exists
+ }
+ return payload, nil, false
+}
diff --git a/backend/internal/launchcode/automation_public_api_timeout.go b/backend/internal/launchcode/automation_public_api_timeout.go
new file mode 100644
index 00000000..1c5347aa
--- /dev/null
+++ b/backend/internal/launchcode/automation_public_api_timeout.go
@@ -0,0 +1,23 @@
+package launchcode
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int {
+ if requestTimeout > 0 {
+ return requestTimeout
+ }
+
+ if r != nil {
+ if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" {
+ if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
+ return parsed
+ }
+ }
+ }
+
+ return fallback
+}
diff --git a/backend/internal/proxy/check_config.go b/backend/internal/proxy/check_config.go
new file mode 100644
index 00000000..a18b75cf
--- /dev/null
+++ b/backend/internal/proxy/check_config.go
@@ -0,0 +1,120 @@
+package proxy
+
+import (
+ "ant-chrome/backend/internal/config"
+ "strings"
+ "time"
+)
+
+const defaultBridgeStartTimeoutMs = 15000
+const defaultTargetTimeoutMs = 10000
+
+func NormalizeCheckSettings(settings config.ProxyCheckConfig) config.ProxyCheckConfig {
+ settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, defaultBridgeStartTimeoutMs)
+ settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID)
+ settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID)
+ settings.Targets = NormalizeCheckTargets(settings.Targets)
+ if len(settings.Targets) == 0 {
+ settings.Targets = config.DefaultConfig().ProxyCheck.Targets
+ }
+ if settings.SpeedTargetID == "" {
+ settings.SpeedTargetID = FirstCheckTargetID(settings.Targets, "speed", "")
+ }
+ if settings.IPHealthTargetID == "" {
+ settings.IPHealthTargetID = FirstCheckTargetID(settings.Targets, "ip_health", "")
+ }
+ return settings
+}
+
+func BuildSpeedTestConfig(settings config.ProxyCheckConfig) *SpeedTestConfig {
+ cfg := DefaultSpeedTestConfig
+ target := FindCheckTarget(settings.Targets, settings.SpeedTargetID, "speed")
+ if strings.TrimSpace(target.URL) != "" {
+ cfg.URLs = []string{strings.TrimSpace(target.URL)}
+ }
+ if target.TimeoutMs > 0 {
+ cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
+ }
+ return &cfg
+}
+
+func BuildIPHealthConfig(settings config.ProxyCheckConfig) *IPHealthConfig {
+ cfg := &IPHealthConfig{Source: "ip_health"}
+ target := FindCheckTarget(settings.Targets, settings.IPHealthTargetID, "ip_health")
+ if strings.TrimSpace(target.URL) != "" {
+ cfg.URL = strings.TrimSpace(target.URL)
+ }
+ if strings.TrimSpace(target.ID) != "" {
+ cfg.Source = strings.TrimSpace(target.ID)
+ }
+ if strings.TrimSpace(target.Parser) != "" {
+ cfg.Parser = strings.TrimSpace(target.Parser)
+ }
+ if target.TimeoutMs > 0 {
+ cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond
+ }
+ return cfg
+}
+
+func FindCheckTarget(targets []config.ProxyCheckTarget, id string, targetType string) config.ProxyCheckTarget {
+ normalizedID := strings.TrimSpace(id)
+ normalizedType := strings.TrimSpace(targetType)
+ for _, target := range targets {
+ if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) {
+ return target
+ }
+ }
+ for _, target := range targets {
+ if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) {
+ return target
+ }
+ }
+ return config.ProxyCheckTarget{}
+}
+
+func NormalizeCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget {
+ result := make([]config.ProxyCheckTarget, 0, len(targets))
+ seen := map[string]struct{}{}
+ for _, target := range targets {
+ target.ID = strings.TrimSpace(target.ID)
+ target.Name = strings.TrimSpace(target.Name)
+ target.Type = strings.TrimSpace(target.Type)
+ target.URL = strings.TrimSpace(target.URL)
+ target.Parser = strings.TrimSpace(target.Parser)
+ if target.ID == "" || target.URL == "" {
+ continue
+ }
+ key := strings.ToLower(target.ID)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ if target.Name == "" {
+ target.Name = target.ID
+ }
+ if target.Type == "" {
+ target.Type = "speed"
+ }
+ if target.TimeoutMs <= 0 {
+ target.TimeoutMs = defaultTargetTimeoutMs
+ }
+ result = append(result, target)
+ }
+ return result
+}
+
+func FirstCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string {
+ for _, target := range targets {
+ if strings.EqualFold(strings.TrimSpace(target.Type), targetType) {
+ return strings.TrimSpace(target.ID)
+ }
+ }
+ return fallback
+}
+
+func normalizePositiveInt(value int, fallback int) int {
+ if value > 0 {
+ return value
+ }
+ return fallback
+}
diff --git a/backend/internal/proxy/check_config_test.go b/backend/internal/proxy/check_config_test.go
new file mode 100644
index 00000000..1514fc6a
--- /dev/null
+++ b/backend/internal/proxy/check_config_test.go
@@ -0,0 +1,65 @@
+package proxy
+
+import (
+ "ant-chrome/backend/internal/config"
+ "testing"
+ "time"
+)
+
+func TestNormalizeCheckSettingsDefaultsAndSelectsTargets(t *testing.T) {
+ settings := NormalizeCheckSettings(config.ProxyCheckConfig{
+ Targets: []config.ProxyCheckTarget{
+ {ID: " speed-main ", URL: " https://speed.example.com ", Type: " speed ", TimeoutMs: 0},
+ {ID: "health-main", URL: "https://health.example.com", Type: "ip_health", Parser: " ipqualityscore ", TimeoutMs: 1500},
+ },
+ })
+
+ if settings.BridgeStartTimeoutMs != defaultBridgeStartTimeoutMs {
+ t.Fatalf("bridge timeout = %d", settings.BridgeStartTimeoutMs)
+ }
+ if settings.SpeedTargetID != "speed-main" {
+ t.Fatalf("speed target id = %q", settings.SpeedTargetID)
+ }
+ if settings.IPHealthTargetID != "health-main" {
+ t.Fatalf("ip health target id = %q", settings.IPHealthTargetID)
+ }
+ if settings.Targets[0].TimeoutMs != defaultTargetTimeoutMs {
+ t.Fatalf("target timeout = %d", settings.Targets[0].TimeoutMs)
+ }
+}
+
+func TestNormalizeCheckTargetsDropsInvalidAndDuplicateTargets(t *testing.T) {
+ targets := NormalizeCheckTargets([]config.ProxyCheckTarget{
+ {ID: "main", URL: "https://example.com"},
+ {ID: " MAIN ", URL: "https://duplicate.example.com"},
+ {ID: "missing-url"},
+ })
+
+ if len(targets) != 1 {
+ t.Fatalf("len = %d", len(targets))
+ }
+ if targets[0].Name != "main" || targets[0].Type != "speed" {
+ t.Fatalf("target defaults were not applied: %#v", targets[0])
+ }
+}
+
+func TestBuildProxyCheckConfigs(t *testing.T) {
+ settings := config.ProxyCheckConfig{
+ SpeedTargetID: "speed-main",
+ IPHealthTargetID: "health-main",
+ Targets: []config.ProxyCheckTarget{
+ {ID: "speed-main", Type: "speed", URL: "https://speed.example.com", TimeoutMs: 1200},
+ {ID: "health-main", Type: "ip_health", URL: "https://health.example.com", Parser: "ipqualityscore", TimeoutMs: 2300},
+ },
+ }
+
+ speed := BuildSpeedTestConfig(settings)
+ if len(speed.URLs) != 1 || speed.URLs[0] != "https://speed.example.com" || speed.Timeout != 1200*time.Millisecond {
+ t.Fatalf("speed config = %#v", speed)
+ }
+
+ health := BuildIPHealthConfig(settings)
+ if health.URL != "https://health.example.com" || health.Source != "health-main" || health.Parser != "ipqualityscore" || health.Timeout != 2300*time.Millisecond {
+ t.Fatalf("health config = %#v", health)
+ }
+}
diff --git a/backend/internal/proxy/normalize.go b/backend/internal/proxy/normalize.go
new file mode 100644
index 00000000..4888775d
--- /dev/null
+++ b/backend/internal/proxy/normalize.go
@@ -0,0 +1,85 @@
+package proxy
+
+import (
+ "ant-chrome/backend/internal/config"
+ "strings"
+)
+
+const defaultSourceRefreshIntervalM = 60
+const maxSourceRefreshIntervalM = 24 * 60
+
+func NormalizeBrowserProxies(proxies []config.BrowserProxy, generateID func() string) []config.BrowserProxy {
+ normalized := make([]config.BrowserProxy, 0, len(proxies)+1)
+ for i, item := range proxies {
+ proxyName := strings.TrimSpace(item.ProxyName)
+ proxyConfig := strings.TrimSpace(item.ProxyConfig)
+ if proxyName == "" || proxyConfig == "" {
+ continue
+ }
+
+ proxyID := strings.TrimSpace(item.ProxyId)
+ if proxyID == "" && generateID != nil {
+ proxyID = generateID()
+ }
+
+ sourceURL := strings.TrimSpace(item.SourceURL)
+ sourceID := strings.TrimSpace(item.SourceID)
+ sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix)
+ sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt)
+ sourceRefreshIntervalM := item.SourceRefreshIntervalM
+ if sourceRefreshIntervalM < 0 {
+ sourceRefreshIntervalM = 0
+ }
+ if sourceRefreshIntervalM > maxSourceRefreshIntervalM {
+ sourceRefreshIntervalM = maxSourceRefreshIntervalM
+ }
+
+ sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != ""
+ if sourceAutoRefresh && sourceRefreshIntervalM <= 0 {
+ sourceRefreshIntervalM = defaultSourceRefreshIntervalM
+ }
+ if !sourceAutoRefresh {
+ sourceRefreshIntervalM = 0
+ }
+ if sourceURL == "" {
+ sourceID = ""
+ sourceNamePrefix = ""
+ sourceLastRefreshAt = ""
+ sourceAutoRefresh = false
+ sourceRefreshIntervalM = 0
+ }
+
+ normalized = append(normalized, config.BrowserProxy{
+ ProxyId: proxyID,
+ ProxyName: proxyName,
+ ProxyConfig: proxyConfig,
+ DnsServers: strings.TrimSpace(item.DnsServers),
+ GroupName: strings.TrimSpace(item.GroupName),
+ SourceID: sourceID,
+ SourceURL: sourceURL,
+ SourceNamePrefix: sourceNamePrefix,
+ SourceAutoRefresh: sourceAutoRefresh,
+ SourceRefreshIntervalM: sourceRefreshIntervalM,
+ SourceLastRefreshAt: sourceLastRefreshAt,
+ SortOrder: i,
+ })
+ }
+
+ return ensureBuiltinDirectProxy(normalized)
+}
+
+func ensureBuiltinDirectProxy(proxies []config.BrowserProxy) []config.BrowserProxy {
+ const directProxyID = "__direct__"
+ for _, item := range proxies {
+ if item.ProxyId == directProxyID {
+ return proxies
+ }
+ }
+
+ builtin := config.BrowserProxy{
+ ProxyId: directProxyID,
+ ProxyName: "直连(不走代理)",
+ ProxyConfig: "direct://",
+ }
+ return append([]config.BrowserProxy{builtin}, proxies...)
+}
diff --git a/backend/internal/proxy/normalize_test.go b/backend/internal/proxy/normalize_test.go
new file mode 100644
index 00000000..9f0e2983
--- /dev/null
+++ b/backend/internal/proxy/normalize_test.go
@@ -0,0 +1,75 @@
+package proxy
+
+import (
+ "ant-chrome/backend/internal/config"
+ "testing"
+)
+
+func TestNormalizeBrowserProxiesTrimsAndAddsBuiltin(t *testing.T) {
+ proxies := NormalizeBrowserProxies([]config.BrowserProxy{
+ {ProxyName: " main ", ProxyConfig: " http://127.0.0.1:8080 ", DnsServers: " 1.1.1.1 ", GroupName: " group-a "},
+ {ProxyName: "missing config"},
+ }, func() string { return "generated-id" })
+
+ if len(proxies) != 2 {
+ t.Fatalf("len = %d, want 2", len(proxies))
+ }
+ if proxies[0].ProxyId != "__direct__" {
+ t.Fatalf("first proxy id = %q, want builtin direct", proxies[0].ProxyId)
+ }
+ if proxies[1].ProxyId != "generated-id" {
+ t.Fatalf("generated proxy id = %q", proxies[1].ProxyId)
+ }
+ if proxies[1].ProxyName != "main" || proxies[1].ProxyConfig != "http://127.0.0.1:8080" {
+ t.Fatalf("proxy was not trimmed: %#v", proxies[1])
+ }
+ if proxies[1].DnsServers != "1.1.1.1" || proxies[1].GroupName != "group-a" {
+ t.Fatalf("metadata was not trimmed: %#v", proxies[1])
+ }
+}
+
+func TestNormalizeBrowserProxiesSourceRefreshRules(t *testing.T) {
+ proxies := NormalizeBrowserProxies([]config.BrowserProxy{
+ {
+ ProxyId: "p1",
+ ProxyName: "source",
+ ProxyConfig: "http://127.0.0.1:8080",
+ SourceID: " source-id ",
+ SourceURL: " https://example.com/proxies.txt ",
+ SourceNamePrefix: " prefix ",
+ SourceAutoRefresh: true,
+ SourceRefreshIntervalM: -1,
+ SourceLastRefreshAt: " now ",
+ },
+ {
+ ProxyId: "p2",
+ ProxyName: "without-source",
+ ProxyConfig: "http://127.0.0.1:8081",
+ SourceID: "source-id",
+ SourceNamePrefix: "prefix",
+ SourceAutoRefresh: true,
+ SourceRefreshIntervalM: 9999,
+ SourceLastRefreshAt: "now",
+ },
+ }, nil)
+
+ if proxies[1].SourceRefreshIntervalM != defaultSourceRefreshIntervalM {
+ t.Fatalf("source refresh interval = %d", proxies[1].SourceRefreshIntervalM)
+ }
+ if !proxies[1].SourceAutoRefresh {
+ t.Fatalf("source auto refresh should stay enabled")
+ }
+ if proxies[2].SourceID != "" || proxies[2].SourceAutoRefresh || proxies[2].SourceRefreshIntervalM != 0 {
+ t.Fatalf("source fields should be cleared without source url: %#v", proxies[2])
+ }
+}
+
+func TestNormalizeBrowserProxiesKeepsExistingBuiltin(t *testing.T) {
+ proxies := NormalizeBrowserProxies([]config.BrowserProxy{
+ {ProxyId: "__direct__", ProxyName: "direct", ProxyConfig: "direct://"},
+ }, nil)
+
+ if len(proxies) != 1 {
+ t.Fatalf("len = %d, want 1", len(proxies))
+ }
+}
diff --git a/backend/app_snapshot_archive.go b/backend/internal/snapshot/archive.go
similarity index 88%
rename from backend/app_snapshot_archive.go
rename to backend/internal/snapshot/archive.go
index ac31712b..1d4b3079 100644
--- a/backend/app_snapshot_archive.go
+++ b/backend/internal/snapshot/archive.go
@@ -1,4 +1,4 @@
-package backend
+package snapshot
import (
"archive/zip"
@@ -10,8 +10,7 @@ import (
"strings"
)
-// zipDir 递归压缩 src 目录为 dest zip 文件
-func zipDir(src, dest string) error {
+func ZipDir(src, dest string) error {
f, err := os.Create(dest)
if err != nil {
return err
@@ -51,8 +50,7 @@ func zipDir(src, dest string) error {
})
}
-// unzipTo 解压 src zip 文件到 dest 目录
-func unzipTo(src, dest string) error {
+func UnzipTo(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
diff --git a/backend/internal/snapshot/archive_test.go b/backend/internal/snapshot/archive_test.go
new file mode 100644
index 00000000..03218152
--- /dev/null
+++ b/backend/internal/snapshot/archive_test.go
@@ -0,0 +1,38 @@
+package snapshot
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestZipDirAndUnzipTo(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ src := filepath.Join(root, "src")
+ dstZip := filepath.Join(root, "archive.zip")
+ dstDir := filepath.Join(root, "dst")
+
+ if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
+ t.Fatalf("mkdir src: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatalf("write source file: %v", err)
+ }
+
+ if err := ZipDir(src, dstZip); err != nil {
+ t.Fatalf("ZipDir failed: %v", err)
+ }
+ if err := UnzipTo(dstZip, dstDir); err != nil {
+ t.Fatalf("UnzipTo failed: %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt"))
+ if err != nil {
+ t.Fatalf("read extracted file: %v", err)
+ }
+ if string(data) != "hello" {
+ t.Fatalf("extracted content = %q, want hello", string(data))
+ }
+}
diff --git a/backend/internal/snapshot/files.go b/backend/internal/snapshot/files.go
new file mode 100644
index 00000000..a65c9b5a
--- /dev/null
+++ b/backend/internal/snapshot/files.go
@@ -0,0 +1,34 @@
+package snapshot
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func EnsureDir(appDataRoot, profileID string) (string, error) {
+ dir := filepath.Join(appDataRoot, "snapshots", profileID)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+func FindFiles(snapDir, snapshotID string) (metaPath, zipPath string, err error) {
+ entries, err := os.ReadDir(snapDir)
+ if err != nil {
+ return "", "", err
+ }
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), snapshotID) && strings.HasSuffix(entry.Name(), ".meta.json") {
+ metaPath = filepath.Join(snapDir, entry.Name())
+ zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip"
+ if _, err := os.Stat(zipPath); err != nil {
+ return "", "", fmt.Errorf("快照文件不存在: %s", zipPath)
+ }
+ return metaPath, zipPath, nil
+ }
+ }
+ return "", "", fmt.Errorf("快照不存在: %s", snapshotID)
+}
diff --git a/backend/internal/snapshot/files_test.go b/backend/internal/snapshot/files_test.go
new file mode 100644
index 00000000..30d15935
--- /dev/null
+++ b/backend/internal/snapshot/files_test.go
@@ -0,0 +1,50 @@
+package snapshot
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFindFiles(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ metaPath := filepath.Join(dir, "snap-1_demo.meta.json")
+ zipPath := filepath.Join(dir, "snap-1_demo.zip")
+
+ if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil {
+ t.Fatalf("write meta: %v", err)
+ }
+ if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil {
+ t.Fatalf("write zip: %v", err)
+ }
+
+ gotMeta, gotZip, err := FindFiles(dir, "snap-1")
+ if err != nil {
+ t.Fatalf("FindFiles failed: %v", err)
+ }
+ if gotMeta != metaPath {
+ t.Fatalf("meta path = %q, want %q", gotMeta, metaPath)
+ }
+ if gotZip != zipPath {
+ t.Fatalf("zip path = %q, want %q", gotZip, zipPath)
+ }
+}
+
+func TestEnsureDir(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ dir, err := EnsureDir(root, "profile-1")
+ if err != nil {
+ t.Fatalf("EnsureDir failed: %v", err)
+ }
+ expected := filepath.Join(root, "snapshots", "profile-1")
+ if dir != expected {
+ t.Fatalf("dir = %q, want %q", dir, expected)
+ }
+ if info, err := os.Stat(dir); err != nil || !info.IsDir() {
+ t.Fatalf("dir was not created: info=%v err=%v", info, err)
+ }
+}
diff --git a/backend/test/launchcode/server_automation_public_hook_test.go b/backend/test/launchcode/server_automation_public_hook_test.go
new file mode 100644
index 00000000..7b5bf90a
--- /dev/null
+++ b/backend/test/launchcode/server_automation_public_hook_test.go
@@ -0,0 +1,454 @@
+package launchcode_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/automation"
+)
+
+func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "proton-mail-first-message",
+ Name: "Proton 邮件搜索并读取最新邮件",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "mail/proton-first-message",
+ RequestMode: "standard",
+ ResponseMode: "envelope",
+ TimeoutMs: 120000,
+ },
+ },
+ }
+ starter.runResult = &automation.ScriptRunRecord{
+ ID: "run-hook-1",
+ ScriptID: "proton-mail-first-message",
+ ScriptName: "Proton 邮件搜索并读取最新邮件",
+ Status: "success",
+ Summary: "已返回最新命中邮件内容",
+ ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`,
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{
+ "instance":{"type":"existing","selector":{"code":"BUYER_001"}},
+ "params":{"recipientQuery":"target@example.com"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
+ t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
+ }
+ if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
+ t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
+ }
+ if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
+ t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest)
+ }
+ if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
+ t.Fatalf("targetInput 转换错误: %s", targetInput)
+ }
+ if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
+ t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
+ }
+
+ var resp struct {
+ OK bool `json:"ok"`
+ Status string `json:"status"`
+ Data map[string]interface{} `json:"data"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ if !resp.OK || resp.Status != "success" {
+ t.Fatalf("hook 响应错误: %+v", resp)
+ }
+ if resp.Data["verificationCode"] != "429792" {
+ t.Fatalf("expected data payload, got %+v", resp.Data)
+ }
+}
+
+func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ expectedMode string
+ expectedSelector bool
+ expectedInput string
+ useScriptTarget bool
+ }{
+ {
+ name: "rotate",
+ body: `{
+ "instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}},
+ "params":{"recipientQuery":"target@example.com"}
+ }`,
+ expectedMode: "rotate",
+ expectedInput: `"groupId":"group-a"`,
+ },
+ {
+ name: "create",
+ body: `{
+ "instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"},
+ "params":{"recipientQuery":"target@example.com"}
+ }`,
+ expectedMode: "create",
+ expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`,
+ },
+ {
+ name: "script-default",
+ body: `{
+ "instance":{"type":"script-default"},
+ "params":{"recipientQuery":"target@example.com"}
+ }`,
+ useScriptTarget: true,
+ },
+ {
+ name: "legacy-code",
+ body: `{
+ "code":"BUYER_001",
+ "params":{"recipientQuery":"target@example.com"}
+ }`,
+ expectedSelector: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "proton-mail-first-message",
+ Name: "Proton 邮件搜索并读取最新邮件",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "mail/proton-first-message",
+ RequestMode: "standard",
+ ResponseMode: "envelope",
+ TimeoutMs: 120000,
+ },
+ },
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastRunRequest.TargetMode != tc.expectedMode {
+ t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest)
+ }
+ if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget {
+ t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest)
+ }
+ if tc.expectedInput != "" {
+ targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput)
+ if !strings.Contains(targetInput, tc.expectedInput) {
+ t.Fatalf("targetInput 转换错误: %s", targetInput)
+ }
+ }
+ if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
+ t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest)
+ }
+ })
+ }
+}
+
+func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "proton-mail-first-message",
+ Name: "Proton 邮件搜索并读取最新邮件",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "mail/proton-result-only",
+ RequestMode: "params-only",
+ ResponseMode: "result-only",
+ TimeoutMs: 45000,
+ },
+ },
+ }
+ starter.runResult = &automation.ScriptRunRecord{
+ ID: "run-hook-2",
+ ScriptID: "proton-mail-first-message",
+ ScriptName: "Proton 邮件搜索并读取最新邮件",
+ Status: "success",
+ Summary: "已返回最新命中邮件内容",
+ ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
+ "code":"BUYER_001",
+ "params":{"recipientQuery":"target@example.com"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
+ t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
+ }
+ if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
+ t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
+ }
+ if starter.lastRunRequest.TimeoutMs != 60000 {
+ t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ data, ok := resp["data"].(map[string]interface{})
+ if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
+ t.Fatalf("expected data payload, got %+v", resp)
+ }
+}
+
+func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "grok-image-generate-download",
+ Name: "Grok 生成图片并下载",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "image/grok-generate-download",
+ RequestMode: "params-only",
+ ResponseMode: "result-only",
+ TimeoutMs: 300000,
+ },
+ },
+ }
+ starter.runResult = &automation.ScriptRunRecord{
+ ID: "run-grok-image",
+ ScriptID: "grok-image-generate-download",
+ ScriptName: "Grok 生成图片并下载",
+ Status: "success",
+ Summary: "Grok 图片已生成并下载",
+ ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ data, ok := resp["data"].(map[string]interface{})
+ if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
+ t.Fatalf("下载字段缺失: %+v", resp)
+ }
+ if _, exists := data["steps"]; exists {
+ t.Fatalf("不应返回冗余 steps: %+v", resp)
+ }
+ if _, exists := data["runId"]; exists {
+ t.Fatalf("不应返回 runId: %+v", resp)
+ }
+}
+
+func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "image-generate",
+ Name: "图片生成",
+ ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "image/generate",
+ RequestMode: "standard",
+ ResponseMode: "envelope",
+ TimeoutMs: 300000,
+ RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
+ Variables: []automation.ScriptPublicAPIVariable{
+ {Name: "prompt", DefaultValue: "默认提示词", Required: true},
+ {Name: "outputFileName", DefaultValue: "generated-image.png"},
+ },
+ },
+ },
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
+ "code":"BUYER_001",
+ "params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastRunRequest.UseScriptParams {
+ t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
+ }
+ if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
+ t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest)
+ }
+ if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
+ t.Fatalf("变量模板 targetInput 错误: %s", targetInput)
+ }
+
+ var params map[string]interface{}
+ if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
+ t.Fatalf("解析 paramsText 失败: %v", err)
+ }
+ if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
+ t.Fatalf("变量未映射到 params: %+v", params)
+ }
+ selectors, ok := params["selectors"].(map[string]interface{})
+ if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
+ t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
+ }
+ if params["timeoutMs"] != float64(300000) {
+ t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
+ }
+ if starter.lastRunRequest.TimeoutMs != 300000 {
+ t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
+ }
+}
+
+func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "disabled-hook",
+ Name: "Disabled Hook",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: false,
+ Path: "mail/disabled-hook",
+ },
+ },
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+}
+
+func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "strict-hook",
+ Name: "Strict Hook",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "mail/strict-hook",
+ TimeoutMs: 120000,
+ },
+ },
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ cases := []struct {
+ name string
+ body string
+ }{
+ {name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
+ {name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ OK bool `json:"ok"`
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ if resp.OK || resp.Error.Code != "invalid_request" {
+ t.Fatalf("错误 envelope 不正确: %+v", resp)
+ }
+ })
+ }
+}
+
+func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.scripts = []automation.ScriptRecord{
+ {
+ ID: "strict-hook-timeout",
+ Name: "Strict Hook Timeout",
+ PublicAPI: automation.ScriptPublicAPIConfig{
+ Enabled: true,
+ Method: "POST",
+ Path: "mail/strict-hook-timeout",
+ TimeoutMs: 120000,
+ },
+ },
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
+ t.Fatalf("错误信息不正确: %s", w.Body.String())
+ }
+}
diff --git a/backend/test/launchcode/server_automation_runs_test.go b/backend/test/launchcode/server_automation_runs_test.go
new file mode 100644
index 00000000..a986a5f1
--- /dev/null
+++ b/backend/test/launchcode/server_automation_runs_test.go
@@ -0,0 +1,108 @@
+package launchcode_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/automation"
+)
+
+func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ starter.runs = []automation.ScriptRunRecord{
+ {ID: "run-1", ScriptID: "script-a", Status: "success"},
+ {ID: "run-2", ScriptID: "script-b", Status: "failed"},
+ }
+
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastRunListLimit != 1 {
+ t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
+ }
+
+ var resp struct {
+ OK bool `json:"ok"`
+ Data struct {
+ Count int `json:"count"`
+ Items []automation.ScriptRunRecord `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
+ t.Fatalf("runs 响应错误: %+v", resp)
+ }
+}
+
+func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
+ handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+}
+
+func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockAutomationStarter()
+ handler := buildTestHandlerWithManager(svc, starter, nil)
+
+ t.Run("invalid-json", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("selector-must-be-object", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
+ "scriptId":"news-query-txt",
+ "selector":"BUYER_001"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
+ t.Fatalf("错误信息不正确: %s", w.Body.String())
+ }
+ })
+
+ t.Run("timeout-must-be-in-range", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
+ "scriptId":"news-query-txt",
+ "timeoutMs":1800001
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
+ t.Fatalf("错误信息不正确: %s", w.Body.String())
+ }
+ })
+}
diff --git a/backend/test/launchcode/server_automation_test.go b/backend/test/launchcode/server_automation_test.go
index eef31dbf..4087b70a 100644
--- a/backend/test/launchcode/server_automation_test.go
+++ b/backend/test/launchcode/server_automation_test.go
@@ -83,6 +83,15 @@ func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation
return items, nil
}
+func automationRunTargetInputJSON(t *testing.T, value interface{}) string {
+ t.Helper()
+ data, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal targetInput failed: %v", err)
+ }
+ return string(data)
+}
+
func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
@@ -311,443 +320,3 @@ func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testi
t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest)
}
}
-
-func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "proton-mail-first-message",
- Name: "Proton 邮件搜索并读取最新邮件",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "mail/proton-first-message",
- RequestMode: "standard",
- ResponseMode: "envelope",
- TimeoutMs: 120000,
- },
- },
- }
- starter.runResult = &automation.ScriptRunRecord{
- ID: "run-hook-1",
- ScriptID: "proton-mail-first-message",
- ScriptName: "Proton 邮件搜索并读取最新邮件",
- Status: "success",
- Summary: "已返回最新命中邮件内容",
- ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`,
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{
- "code":"BUYER_001",
- "params":{"recipientQuery":"target@example.com"}
- }`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
- t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
- }
- if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
- t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
- }
- if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
- t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText)
- }
- if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
- t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
- }
-
- var resp struct {
- OK bool `json:"ok"`
- Status string `json:"status"`
- Data map[string]interface{} `json:"data"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- if !resp.OK || resp.Status != "success" {
- t.Fatalf("hook 响应错误: %+v", resp)
- }
- if resp.Data["verificationCode"] != "429792" {
- t.Fatalf("expected data payload, got %+v", resp.Data)
- }
-}
-
-func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "proton-mail-first-message",
- Name: "Proton 邮件搜索并读取最新邮件",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "mail/proton-result-only",
- RequestMode: "params-only",
- ResponseMode: "result-only",
- TimeoutMs: 45000,
- },
- },
- }
- starter.runResult = &automation.ScriptRunRecord{
- ID: "run-hook-2",
- ScriptID: "proton-mail-first-message",
- ScriptName: "Proton 邮件搜索并读取最新邮件",
- Status: "success",
- Summary: "已返回最新命中邮件内容",
- ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
- "code":"BUYER_001",
- "params":{"recipientQuery":"target@example.com"}
- }`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
- t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
- }
- if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
- t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
- }
- if starter.lastRunRequest.TimeoutMs != 60000 {
- t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
- }
-
- var resp map[string]interface{}
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- data, ok := resp["data"].(map[string]interface{})
- if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
- t.Fatalf("expected data payload, got %+v", resp)
- }
-}
-
-func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "grok-image-generate-download",
- Name: "Grok 生成图片并下载",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "image/grok-generate-download",
- RequestMode: "params-only",
- ResponseMode: "result-only",
- TimeoutMs: 300000,
- },
- },
- }
- starter.runResult = &automation.ScriptRunRecord{
- ID: "run-grok-image",
- ScriptID: "grok-image-generate-download",
- ScriptName: "Grok 生成图片并下载",
- Status: "success",
- Summary: "Grok 图片已生成并下载",
- ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- data, ok := resp["data"].(map[string]interface{})
- if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
- t.Fatalf("下载字段缺失: %+v", resp)
- }
- if _, exists := data["steps"]; exists {
- t.Fatalf("不应返回冗余 steps: %+v", resp)
- }
- if _, exists := data["runId"]; exists {
- t.Fatalf("不应返回 runId: %+v", resp)
- }
-}
-
-func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "image-generate",
- Name: "图片生成",
- ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "image/generate",
- RequestMode: "standard",
- ResponseMode: "envelope",
- TimeoutMs: 300000,
- RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
- Variables: []automation.ScriptPublicAPIVariable{
- {Name: "prompt", DefaultValue: "默认提示词", Required: true},
- {Name: "outputFileName", DefaultValue: "generated-image.png"},
- },
- },
- },
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
- "code":"BUYER_001",
- "params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
- }`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastRunRequest.UseScriptParams {
- t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
- }
- if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
- t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest)
- }
-
- var params map[string]interface{}
- if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
- t.Fatalf("解析 paramsText 失败: %v", err)
- }
- if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
- t.Fatalf("变量未映射到 params: %+v", params)
- }
- selectors, ok := params["selectors"].(map[string]interface{})
- if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
- t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
- }
- if params["timeoutMs"] != float64(300000) {
- t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
- }
- if starter.lastRunRequest.TimeoutMs != 300000 {
- t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
- }
-}
-
-func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "disabled-hook",
- Name: "Disabled Hook",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: false,
- Path: "mail/disabled-hook",
- },
- },
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
- }
-}
-
-func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "strict-hook",
- Name: "Strict Hook",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "mail/strict-hook",
- TimeoutMs: 120000,
- },
- },
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- cases := []struct {
- name string
- body string
- }{
- {name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
- {name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
- }
-
- var resp struct {
- OK bool `json:"ok"`
- Error struct {
- Code string `json:"code"`
- } `json:"error"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- if resp.OK || resp.Error.Code != "invalid_request" {
- t.Fatalf("错误 envelope 不正确: %+v", resp)
- }
- })
- }
-}
-
-func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.scripts = []automation.ScriptRecord{
- {
- ID: "strict-hook-timeout",
- Name: "Strict Hook Timeout",
- PublicAPI: automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "mail/strict-hook-timeout",
- TimeoutMs: 120000,
- },
- },
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
- t.Fatalf("错误信息不正确: %s", w.Body.String())
- }
-}
-
-func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- starter.runs = []automation.ScriptRunRecord{
- {ID: "run-1", ScriptID: "script-a", Status: "success"},
- {ID: "run-2", ScriptID: "script-b", Status: "failed"},
- }
-
- handler := buildTestHandlerWithManager(svc, starter, nil)
- req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastRunListLimit != 1 {
- t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
- }
-
- var resp struct {
- OK bool `json:"ok"`
- Data struct {
- Count int `json:"count"`
- Items []automation.ScriptRunRecord `json:"items"`
- } `json:"data"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
- t.Fatalf("runs 响应错误: %+v", resp)
- }
-}
-
-func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
- handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
- req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
- }
-}
-
-func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockAutomationStarter()
- handler := buildTestHandlerWithManager(svc, starter, nil)
-
- t.Run("invalid-json", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
- }
- })
-
- t.Run("selector-must-be-object", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
- "scriptId":"news-query-txt",
- "selector":"BUYER_001"
- }`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
- t.Fatalf("错误信息不正确: %s", w.Body.String())
- }
- })
-
- t.Run("timeout-must-be-in-range", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
- "scriptId":"news-query-txt",
- "timeoutMs":1800001
- }`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
- t.Fatalf("错误信息不正确: %s", w.Body.String())
- }
- })
-}
diff --git a/backend/test/launchcode/server_selector_modes_test.go b/backend/test/launchcode/server_selector_modes_test.go
new file mode 100644
index 00000000..e377c40b
--- /dev/null
+++ b/backend/test/launchcode/server_selector_modes_test.go
@@ -0,0 +1,214 @@
+package launchcode_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "ant-chrome/backend/internal/browser"
+)
+
+func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockStarterWithParams()
+
+ profileA := &browser.Profile{
+ ProfileId: "profile-a",
+ ProfileName: "Account A",
+ Keywords: []string{"shop", "checkout"},
+ Pid: 1001,
+ DebugPort: 9441,
+ }
+ profileB := &browser.Profile{
+ ProfileId: "profile-b",
+ ProfileName: "Account B",
+ Keywords: []string{"shop", "refund"},
+ Pid: 1002,
+ DebugPort: 9442,
+ }
+ starter.addProfile(profileA)
+ starter.addProfile(profileB)
+ manager := newSelectorTestManager(profileA, profileB)
+
+ handler := buildTestHandlerWithManager(svc, starter, manager)
+ req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastProfile != "" {
+ t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
+ }
+ if !strings.Contains(w.Body.String(), "matchMode=first") {
+ t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
+ }
+}
+
+func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockStarterWithParams()
+
+ profile := &browser.Profile{
+ ProfileId: "profile-get-code-only",
+ ProfileName: "Buyer Account 02",
+ Keywords: []string{"buyer-002"},
+ Pid: 1004,
+ DebugPort: 9447,
+ }
+ starter.addProfile(profile)
+ manager := newSelectorTestManager(profile)
+
+ handler := buildTestHandlerWithManager(svc, starter, manager)
+ req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastProfile != "" {
+ t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
+ }
+}
+
+func TestLaunchWithMatchModeFirst(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockStarterWithParams()
+
+ profileB := &browser.Profile{
+ ProfileId: "profile-b",
+ ProfileName: "B Account",
+ Keywords: []string{"shop"},
+ Pid: 2002,
+ DebugPort: 9552,
+ }
+ profileA := &browser.Profile{
+ ProfileId: "profile-a",
+ ProfileName: "A Account",
+ Keywords: []string{"shop"},
+ Pid: 2001,
+ DebugPort: 9551,
+ }
+ starter.addProfile(profileA)
+ starter.addProfile(profileB)
+ manager := newSelectorTestManager(profileB, profileA)
+
+ handler := buildTestHandlerWithManager(svc, starter, manager)
+ req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if starter.lastProfile != profileA.ProfileId {
+ t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
+ }
+}
+
+func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockStarterWithParams()
+
+ profileA := &browser.Profile{
+ ProfileId: "profile-a",
+ ProfileName: "A Account",
+ Keywords: []string{"shop"},
+ Pid: 2001,
+ DebugPort: 9551,
+ }
+ profileB := &browser.Profile{
+ ProfileId: "profile-b",
+ ProfileName: "B Account",
+ Keywords: []string{"shop"},
+ Pid: 2002,
+ DebugPort: 9552,
+ }
+ starter.addProfile(profileA)
+ starter.addProfile(profileB)
+ manager := newSelectorTestManager(profileB, profileA)
+
+ handler := buildTestHandlerWithManager(svc, starter, manager)
+ req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if len(starter.started) != 2 {
+ t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
+ }
+ if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
+ t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
+ }
+
+ var resp struct {
+ OK bool `json:"ok"`
+ Count int `json:"count"`
+ Items []struct {
+ ProfileID string `json:"profileId"`
+ IsActive bool `json:"isActive"`
+ } `json:"items"`
+ ActiveProfileID string `json:"activeProfileId"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("解析响应失败: %v", err)
+ }
+ if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
+ t.Fatalf("批量启动响应错误: %+v", resp)
+ }
+ if resp.ActiveProfileID != profileB.ProfileId {
+ t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
+ }
+ if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
+ t.Fatalf("items 顺序错误: %+v", resp.Items)
+ }
+ if resp.Items[0].IsActive || !resp.Items[1].IsActive {
+ t.Fatalf("isActive 标记错误: %+v", resp.Items)
+ }
+}
+
+func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
+ svc := newInMemoryService()
+ starter := newMockStarterWithParams()
+
+ profileA := &browser.Profile{
+ ProfileId: "profile-a",
+ ProfileName: "Account A",
+ Keywords: []string{"shop", "checkout"},
+ Pid: 1001,
+ DebugPort: 9441,
+ }
+ profileB := &browser.Profile{
+ ProfileId: "profile-b",
+ ProfileName: "Account B",
+ Keywords: []string{"shop", "refund"},
+ Pid: 1002,
+ DebugPort: 9442,
+ }
+ starter.addProfile(profileA)
+ starter.addProfile(profileB)
+ manager := newSelectorTestManager(profileA, profileB)
+
+ handler := buildTestHandlerWithManager(svc, starter, manager)
+ req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
+ }
+ if len(starter.started) != 0 {
+ t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
+ }
+}
diff --git a/backend/test/launchcode/server_selector_test.go b/backend/test/launchcode/server_selector_test.go
index 78fb3c73..0553eb6e 100644
--- a/backend/test/launchcode/server_selector_test.go
+++ b/backend/test/launchcode/server_selector_test.go
@@ -344,205 +344,3 @@ func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) {
t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
}
}
-
-func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockStarterWithParams()
-
- profileA := &browser.Profile{
- ProfileId: "profile-a",
- ProfileName: "Account A",
- Keywords: []string{"shop", "checkout"},
- Pid: 1001,
- DebugPort: 9441,
- }
- profileB := &browser.Profile{
- ProfileId: "profile-b",
- ProfileName: "Account B",
- Keywords: []string{"shop", "refund"},
- Pid: 1002,
- DebugPort: 9442,
- }
- starter.addProfile(profileA)
- starter.addProfile(profileB)
- manager := newSelectorTestManager(profileA, profileB)
-
- handler := buildTestHandlerWithManager(svc, starter, manager)
- req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusConflict {
- t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastProfile != "" {
- t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
- }
- if !strings.Contains(w.Body.String(), "matchMode=first") {
- t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
- }
-}
-
-func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockStarterWithParams()
-
- profile := &browser.Profile{
- ProfileId: "profile-get-code-only",
- ProfileName: "Buyer Account 02",
- Keywords: []string{"buyer-002"},
- Pid: 1004,
- DebugPort: 9447,
- }
- starter.addProfile(profile)
- manager := newSelectorTestManager(profile)
-
- handler := buildTestHandlerWithManager(svc, starter, manager)
- req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastProfile != "" {
- t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
- }
-}
-
-func TestLaunchWithMatchModeFirst(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockStarterWithParams()
-
- profileB := &browser.Profile{
- ProfileId: "profile-b",
- ProfileName: "B Account",
- Keywords: []string{"shop"},
- Pid: 2002,
- DebugPort: 9552,
- }
- profileA := &browser.Profile{
- ProfileId: "profile-a",
- ProfileName: "A Account",
- Keywords: []string{"shop"},
- Pid: 2001,
- DebugPort: 9551,
- }
- starter.addProfile(profileA)
- starter.addProfile(profileB)
- manager := newSelectorTestManager(profileB, profileA)
-
- handler := buildTestHandlerWithManager(svc, starter, manager)
- req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if starter.lastProfile != profileA.ProfileId {
- t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
- }
-}
-
-func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockStarterWithParams()
-
- profileA := &browser.Profile{
- ProfileId: "profile-a",
- ProfileName: "A Account",
- Keywords: []string{"shop"},
- Pid: 2001,
- DebugPort: 9551,
- }
- profileB := &browser.Profile{
- ProfileId: "profile-b",
- ProfileName: "B Account",
- Keywords: []string{"shop"},
- Pid: 2002,
- DebugPort: 9552,
- }
- starter.addProfile(profileA)
- starter.addProfile(profileB)
- manager := newSelectorTestManager(profileB, profileA)
-
- handler := buildTestHandlerWithManager(svc, starter, manager)
- req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if len(starter.started) != 2 {
- t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
- }
- if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
- t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
- }
-
- var resp struct {
- OK bool `json:"ok"`
- Count int `json:"count"`
- Items []struct {
- ProfileID string `json:"profileId"`
- IsActive bool `json:"isActive"`
- } `json:"items"`
- ActiveProfileID string `json:"activeProfileId"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("解析响应失败: %v", err)
- }
- if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
- t.Fatalf("批量启动响应错误: %+v", resp)
- }
- if resp.ActiveProfileID != profileB.ProfileId {
- t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
- }
- if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
- t.Fatalf("items 顺序错误: %+v", resp.Items)
- }
- if resp.Items[0].IsActive || !resp.Items[1].IsActive {
- t.Fatalf("isActive 标记错误: %+v", resp.Items)
- }
-}
-
-func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
- svc := newInMemoryService()
- starter := newMockStarterWithParams()
-
- profileA := &browser.Profile{
- ProfileId: "profile-a",
- ProfileName: "Account A",
- Keywords: []string{"shop", "checkout"},
- Pid: 1001,
- DebugPort: 9441,
- }
- profileB := &browser.Profile{
- ProfileId: "profile-b",
- ProfileName: "Account B",
- Keywords: []string{"shop", "refund"},
- Pid: 1002,
- DebugPort: 9442,
- }
- starter.addProfile(profileA)
- starter.addProfile(profileB)
- manager := newSelectorTestManager(profileA, profileB)
-
- handler := buildTestHandlerWithManager(svc, starter, manager)
- req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler.ServeHTTP(w, req)
-
- if w.Code != http.StatusConflict {
- t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
- }
- if len(starter.started) != 0 {
- t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
- }
-}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d796082f..5dc41216 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,15 +1,11 @@
-import { Suspense, lazy, useEffect, useState } from "react";
-import type { ComponentType } from "react";
-import {
- BrowserRouter as Router,
- Routes,
- Route,
- Navigate,
-} from "react-router-dom";
+import { Suspense, useEffect, useState } from "react";
+import { BrowserRouter as Router } from "react-router-dom";
import { ThemeProvider } from "./shared/theme";
import { Layout } from "./shared/layout";
import { ToastContainer, Modal, Button, Loading, toast } from "./shared/components";
import { AlertCircle } from "lucide-react";
+import { AppRoutes } from "./routes/AppRoutes";
+import { lazyNamed } from "./routes/lazyNamed";
import { useNotificationStore } from "./store/notificationStore";
import { useBackupStore } from "./store/backupStore";
import {
@@ -23,127 +19,6 @@ import {
WindowMinimise,
} from "./wailsjs/runtime/runtime";
-const CHUNK_RELOAD_COOLDOWN_MS = 10000;
-const CHUNK_RELOAD_TS_KEY = "__ant_chunk_reload_ts__";
-
-function isDynamicImportFetchError(error: unknown) {
- const message =
- error instanceof Error ? error.message : String(error ?? "");
- return /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(
- message,
- );
-}
-
-function reloadForStaleChunkOnce() {
- if (typeof window === "undefined") {
- return false;
- }
-
- const now = Date.now();
- try {
- const lastAttempt = Number(
- window.sessionStorage.getItem(CHUNK_RELOAD_TS_KEY) || "0",
- );
- if (Number.isFinite(lastAttempt) && now - lastAttempt < CHUNK_RELOAD_COOLDOWN_MS) {
- return false;
- }
- window.sessionStorage.setItem(CHUNK_RELOAD_TS_KEY, String(now));
- } catch {
- // ignore sessionStorage failures and still try a hard reload
- }
-
- window.location.reload();
- return true;
-}
-
-function lazyNamed>>(
- loader: () => Promise,
- exportName: keyof TModule,
-) {
- return lazy(async () => {
- let module: TModule;
- try {
- module = await loader();
- } catch (error) {
- if (isDynamicImportFetchError(error) && reloadForStaleChunkOnce()) {
- return new Promise(() => {});
- }
- throw error;
- }
- return {
- default: module[exportName] as ComponentType,
- };
- });
-}
-
-const DashboardPage = lazyNamed(
- () => import("./modules/dashboard/DashboardPage"),
- "DashboardPage",
-);
-const SettingsPage = lazyNamed(
- () => import("./modules/settings/SettingsPage"),
- "SettingsPage",
-);
-const ProfilePage = lazyNamed(
- () => import("./modules/profile/ProfilePage"),
- "ProfilePage",
-);
-const AdminKeygenPage = lazyNamed(
- () => import("./modules/profile/AdminKeygenPage"),
- "AdminKeygenPage",
-);
-const ChartsPage = lazyNamed(
- () => import("./modules/charts/ChartsPage"),
- "ChartsPage",
-);
-const BrowserListPage = lazyNamed(
- () => import("./modules/browser/pages/BrowserListPage"),
- "BrowserListPage",
-);
-const BrowserDetailPage = lazyNamed(
- () => import("./modules/browser/pages/BrowserDetailPage"),
- "BrowserDetailPage",
-);
-const BrowserEditPage = lazyNamed(
- () => import("./modules/browser/pages/BrowserEditPage"),
- "BrowserEditPage",
-);
-const BrowserCopyPage = lazyNamed(
- () => import("./modules/browser/pages/BrowserCopyPage"),
- "BrowserCopyPage",
-);
-const BrowserLogsPage = lazyNamed(
- () => import("./modules/browser/pages/BrowserLogsPage"),
- "BrowserLogsPage",
-);
-const ProxyPoolPage = lazyNamed(
- () => import("./modules/browser/pages/ProxyPoolPage"),
- "ProxyPoolPage",
-);
-const CoreManagementPage = lazyNamed(
- () => import("./modules/browser/pages/CoreManagementPage"),
- "CoreManagementPage",
-);
-const BookmarkSettingsPage = lazyNamed(
- () => import("./modules/browser/pages/BookmarkSettingsPage"),
- "BookmarkSettingsPage",
-);
-const LaunchApiDocsPage = lazyNamed(
- () => import("./modules/browser/pages/LaunchApiDocsPage"),
- "LaunchApiDocsPage",
-);
-const TagManagementPage = lazyNamed(
- () => import("./modules/browser/pages/TagManagementPage"),
- "TagManagementPage",
-);
-const AutomationPage = lazyNamed(
- () => import("./modules/browser/pages/AutomationPage"),
- "AutomationPage",
-);
-const AutomationScriptDetailPage = lazyNamed(
- () => import("./modules/browser/pages/AutomationScriptDetailPage"),
- "AutomationScriptDetailPage",
-);
const QuickLaunchModal = lazyNamed(
() => import("./modules/browser/components/QuickLaunchModal"),
"QuickLaunchModal",
@@ -399,49 +274,7 @@ function App() {
-
- } />
- } />
- } />
- } />
- } />
- } />
- }
- />
- } />
- } />
- }
- />
- } />
- } />
- } />
- }
- />
- } />
- }
- />
- }
- />
- }
- />
- } />
- }
- />
-
+
diff --git a/frontend/src/config/features.config.ts b/frontend/src/config/features.config.ts
new file mode 100644
index 00000000..3626a8e7
--- /dev/null
+++ b/frontend/src/config/features.config.ts
@@ -0,0 +1,5 @@
+export const featuresConfig = {
+ dashboard: true,
+ data: true,
+ settings: true,
+}
diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts
index f5732c09..8540d63c 100644
--- a/frontend/src/config/index.ts
+++ b/frontend/src/config/index.ts
@@ -2,13 +2,13 @@
export { default as config } from './project.config'
export {
projectConfig,
- navigationConfig,
featuresConfig,
uiConfig,
} from './project.config'
+export { navigationConfig } from './navigation.config'
export { profilePageConfig } from './profile.config'
-export type { NavItem, NavSection } from './project.config'
+export type { NavItem, NavSection } from './navigation.config'
export type {
AuthorProfileConfig,
ProfileChannelConfig,
diff --git a/frontend/src/config/navigation.config.ts b/frontend/src/config/navigation.config.ts
new file mode 100644
index 00000000..aab7694a
--- /dev/null
+++ b/frontend/src/config/navigation.config.ts
@@ -0,0 +1,38 @@
+export interface NavItem {
+ name: string
+ path: string
+ icon: string
+}
+
+export interface NavSection {
+ title: string
+ items: NavItem[]
+}
+
+export const navigationConfig: NavSection[] = [
+ {
+ title: '主菜单',
+ items: [
+ { name: '控制台', path: '/', icon: 'LayoutDashboard' },
+ ]
+ },
+ {
+ title: '指纹浏览器',
+ items: [
+ { name: '实例列表', path: '/browser/list', icon: 'Monitor' },
+ { name: '自动化脚本', path: '/browser/automation', icon: 'Bot' },
+ { name: '内核管理', path: '/browser/cores', icon: 'Cpu' },
+ { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' },
+ { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' },
+ { name: '标签管理', path: '/browser/tags', icon: 'Tag' },
+ ]
+ },
+ {
+ title: '系统维护',
+ items: [
+ { name: '系统设置', path: '/settings', icon: 'Settings' },
+ { name: '文档中心', path: '/system/docs', icon: 'BookOpen' },
+ { name: '日志查看', path: '/browser/logs', icon: 'FileText' },
+ ]
+ },
+]
diff --git a/frontend/src/config/profile.config.ts b/frontend/src/config/profile.config.ts
index df3d16c4..e5358cb9 100644
--- a/frontend/src/config/profile.config.ts
+++ b/frontend/src/config/profile.config.ts
@@ -1,4 +1,4 @@
-import { projectConfig } from './project.config'
+import { projectConfig } from './projectBase.config'
import { PROJECT_GITHUB_URL } from './links'
export type ProfileIconKey =
diff --git a/frontend/src/config/project.config.ts b/frontend/src/config/project.config.ts
index 49a044a1..0078900e 100644
--- a/frontend/src/config/project.config.ts
+++ b/frontend/src/config/project.config.ts
@@ -1,73 +1,13 @@
-/**
- * 项目配置文件
- *
- * 基于此脚手架创建新项目时,修改此文件即可完成定制
- */
+import { featuresConfig } from './features.config'
+import { navigationConfig } from './navigation.config'
+import { projectConfig } from './projectBase.config'
+import { uiConfig } from './ui.config'
-// 项目基础信息
-export const projectConfig = {
- name: 'Ant Browser',
- shortName: 'Ant',
- description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具',
- primaryColor: 'primary',
-}
-
-// 导航菜单配置
-export interface NavItem {
- name: string
- path: string
- icon: string
-}
-
-export interface NavSection {
- title: string
- items: NavItem[]
-}
-
-export const navigationConfig: NavSection[] = [
- {
- title: '主菜单',
- items: [
- { name: '控制台', path: '/', icon: 'LayoutDashboard' },
- ]
- },
- {
- title: '指纹浏览器',
- items: [
- { name: '实例列表', path: '/browser/list', icon: 'Monitor' },
- { name: '自动化脚本', path: '/browser/automation', icon: 'Bot' },
- { name: '内核管理', path: '/browser/cores', icon: 'Cpu' },
- { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' },
- { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' },
- { name: '标签管理', path: '/browser/tags', icon: 'Tag' },
- ]
- },
- {
- title: '系统维护',
- items: [
- { name: '系统设置', path: '/settings', icon: 'Settings' },
- { name: '文档中心', path: '/system/docs', icon: 'BookOpen' },
- { name: '日志查看', path: '/browser/logs', icon: 'FileText' },
- ]
- },
-]
-
-// 功能开关
-export const featuresConfig = {
- dashboard: true,
- data: true,
- settings: true,
-}
-
-// UI 配置
-export const uiConfig = {
- pagination: {
- defaultPageSize: 20,
- pageSizeOptions: [10, 20, 50, 100],
- },
- dateFormat: 'YYYY-MM-DD HH:mm:ss',
- locale: 'zh-CN',
-}
+export { featuresConfig } from './features.config'
+export { navigationConfig } from './navigation.config'
+export { projectConfig } from './projectBase.config'
+export { uiConfig } from './ui.config'
+export type { NavItem, NavSection } from './navigation.config'
export default {
project: projectConfig,
diff --git a/frontend/src/config/projectBase.config.ts b/frontend/src/config/projectBase.config.ts
new file mode 100644
index 00000000..edc82d46
--- /dev/null
+++ b/frontend/src/config/projectBase.config.ts
@@ -0,0 +1,6 @@
+export const projectConfig = {
+ name: 'Ant Browser',
+ shortName: 'Ant',
+ description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具',
+ primaryColor: 'primary',
+}
diff --git a/frontend/src/config/ui.config.ts b/frontend/src/config/ui.config.ts
new file mode 100644
index 00000000..ae601c22
--- /dev/null
+++ b/frontend/src/config/ui.config.ts
@@ -0,0 +1,8 @@
+export const uiConfig = {
+ pagination: {
+ defaultPageSize: 20,
+ pageSizeOptions: [10, 20, 50, 100],
+ },
+ dateFormat: 'YYYY-MM-DD HH:mm:ss',
+ locale: 'zh-CN',
+}
diff --git a/frontend/src/modules/browser/automationScriptApi.exports.ts b/frontend/src/modules/browser/automationScriptApi.exports.ts
new file mode 100644
index 00000000..aa6d9a89
--- /dev/null
+++ b/frontend/src/modules/browser/automationScriptApi.exports.ts
@@ -0,0 +1,133 @@
+import { exportAutomationScript, type AutomationScriptRecord } from "./automationScripts";
+import { getBindings, type AutomationScriptExportResult } from "./automationScriptApi.shared";
+
+function normalizeAutomationScriptExportResult(
+ payload: any,
+): AutomationScriptExportResult {
+ return {
+ cancelled: payload?.cancelled === true,
+ format: String(payload?.format || ""),
+ message: String(payload?.message || ""),
+ path: String(payload?.path || ""),
+ fileCount: Number(payload?.fileCount) || 0,
+ };
+}
+
+function buildAutomationTemplateFallbackFilename(script: AutomationScriptRecord): string {
+ const normalizedName = String(script.name || "")
+ .trim()
+ .replace(/[\\/:*?"<>|]+/g, "-")
+ .replace(/\s+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+ return `${normalizedName || "automation-script"}-template.json`;
+}
+
+function downloadAutomationTemplate(
+ filename: string,
+ content: string,
+): AutomationScriptExportResult {
+ const blob = new Blob([content], { type: "application/json;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+
+ try {
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+
+ return {
+ cancelled: false,
+ format: "json",
+ message: "模板已导出",
+ path: filename,
+ fileCount: 1,
+ };
+}
+
+export async function exportAutomationScriptTemplate(
+ scriptId: string,
+ fallbackScript?: AutomationScriptRecord,
+): Promise {
+ const normalizedScriptId = String(scriptId || "").trim();
+ if (!normalizedScriptId) {
+ throw new Error("脚本 ID 不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptExport) {
+ return normalizeAutomationScriptExportResult(
+ await bindings.AutomationScriptExport(normalizedScriptId),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptExport === "function") {
+ return normalizeAutomationScriptExportResult(
+ await goApp.AutomationScriptExport(normalizedScriptId),
+ );
+ }
+
+ if (fallbackScript && typeof document !== "undefined") {
+ return downloadAutomationTemplate(
+ buildAutomationTemplateFallbackFilename(fallbackScript),
+ exportAutomationScript(fallbackScript),
+ );
+ }
+
+ throw new Error("当前环境不支持脚本模板导出");
+}
+
+export async function exportAutomationScriptZip(
+ scriptId: string,
+): Promise {
+ const normalizedScriptId = String(scriptId || "").trim();
+ if (!normalizedScriptId) {
+ throw new Error("脚本 ID 不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptExportZip) {
+ return normalizeAutomationScriptExportResult(
+ await bindings.AutomationScriptExportZip(normalizedScriptId),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptExportZip === "function") {
+ return normalizeAutomationScriptExportResult(
+ await goApp.AutomationScriptExportZip(normalizedScriptId),
+ );
+ }
+
+ throw new Error("当前环境不支持 ZIP 脚本包导出");
+}
+
+export async function exportAutomationScriptDirectory(
+ scriptId: string,
+): Promise {
+ const normalizedScriptId = String(scriptId || "").trim();
+ if (!normalizedScriptId) {
+ throw new Error("脚本 ID 不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptExportDirectory) {
+ return normalizeAutomationScriptExportResult(
+ await bindings.AutomationScriptExportDirectory(normalizedScriptId),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptExportDirectory === "function") {
+ return normalizeAutomationScriptExportResult(
+ await goApp.AutomationScriptExportDirectory(normalizedScriptId),
+ );
+ }
+
+ throw new Error("当前环境不支持目录脚本包导出");
+}
+
diff --git a/frontend/src/modules/browser/automationScriptApi.imports.ts b/frontend/src/modules/browser/automationScriptApi.imports.ts
new file mode 100644
index 00000000..5c14f989
--- /dev/null
+++ b/frontend/src/modules/browser/automationScriptApi.imports.ts
@@ -0,0 +1,190 @@
+import { importAutomationScript, type AutomationScriptRecord } from "./automationScripts";
+import { getBindings, normalizeAutomationScriptRecord, type AutomationScriptBatchImportResult } from "./automationScriptApi.shared";
+
+export async function importAutomationScriptFromLocalFile(): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportLocalFile) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptImportLocalFile(),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportLocalFile === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptImportLocalFile(),
+ );
+ }
+
+ throw new Error("当前环境不支持本地文件导入");
+}
+
+export async function importAutomationScriptFromText(
+ text: string,
+): Promise {
+ const normalizedText = String(text || "").trim();
+ if (!normalizedText) {
+ throw new Error("导入内容不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportText) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptImportText(normalizedText),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportText === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptImportText(normalizedText),
+ );
+ }
+
+ return importAutomationScript(normalizedText);
+}
+
+export async function importAutomationScriptFromLocalDirectory(): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportLocalDirectory) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptImportLocalDirectory(),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportLocalDirectory === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptImportLocalDirectory(),
+ );
+ }
+
+ throw new Error("当前环境不支持本地目录导入");
+}
+
+function normalizeAutomationScriptBatchImportResult(
+ payload: any,
+): AutomationScriptBatchImportResult {
+ const imported = Array.isArray(payload?.imported)
+ ? payload.imported.map(normalizeAutomationScriptRecord)
+ : [];
+
+ return {
+ imported,
+ failed: Array.isArray(payload?.failed)
+ ? payload.failed.map((item: any) => ({
+ path: String(item?.path || ""),
+ message: String(item?.message || ""),
+ }))
+ : [],
+ scanned:
+ Number.isFinite(Number(payload?.scanned)) && Number(payload.scanned) > 0
+ ? Math.round(Number(payload.scanned))
+ : imported.length,
+ };
+}
+
+export async function importAutomationScriptFromLocalLibrary(): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportLocalLibrary) {
+ return normalizeAutomationScriptBatchImportResult(
+ await bindings.AutomationScriptImportLocalLibrary(),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportLocalLibrary === "function") {
+ return normalizeAutomationScriptBatchImportResult(
+ await goApp.AutomationScriptImportLocalLibrary(),
+ );
+ }
+
+ throw new Error("当前环境不支持本地脚本库导入");
+}
+
+export async function importAutomationScriptFromRemote(url: string): Promise {
+ const normalizedURL = String(url || "").trim();
+ if (!normalizedURL) {
+ throw new Error("远程脚本地址不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportRemote) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptImportRemote(normalizedURL),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportRemote === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptImportRemote(normalizedURL),
+ );
+ }
+
+ throw new Error("当前环境不支持远程脚本导入");
+}
+
+export async function importAutomationScriptFromGit(
+ repoURL: string,
+ ref = "",
+ scriptPath = "",
+): Promise {
+ const normalizedRepoURL = String(repoURL || "").trim();
+ if (!normalizedRepoURL) {
+ throw new Error("Git 仓库地址不能为空");
+ }
+
+ const normalizedRef = String(ref || "").trim();
+ const normalizedScriptPath = String(scriptPath || "").trim();
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptImportGit) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptImportGit(
+ normalizedRepoURL,
+ normalizedRef,
+ normalizedScriptPath,
+ ),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptImportGit === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptImportGit(
+ normalizedRepoURL,
+ normalizedRef,
+ normalizedScriptPath,
+ ),
+ );
+ }
+
+ throw new Error("当前环境不支持 Git 脚本导入");
+}
+
+export async function refreshAutomationScript(
+ scriptId: string,
+): Promise {
+ const normalizedScriptId = String(scriptId || "").trim();
+ if (!normalizedScriptId) {
+ throw new Error("脚本 ID 不能为空");
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptRefresh) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptRefresh(normalizedScriptId),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptRefresh === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptRefresh(normalizedScriptId),
+ );
+ }
+
+ throw new Error("当前环境不支持按来源重新导入");
+}
+
diff --git a/frontend/src/modules/browser/automationScriptApi.runs.ts b/frontend/src/modules/browser/automationScriptApi.runs.ts
new file mode 100644
index 00000000..a90537f4
--- /dev/null
+++ b/frontend/src/modules/browser/automationScriptApi.runs.ts
@@ -0,0 +1,164 @@
+import { type AutomationScriptRunInput, type AutomationScriptRunRecord } from "./automationScripts";
+import { startBrowserInstanceByCode } from "./api/instances";
+import { getBindings, normalizeAutomationScriptPublicApiInvokeResult, normalizeAutomationScriptRunInput, normalizeAutomationScriptRunRecord, type AutomationScriptPublicApiInvokeInput, type AutomationScriptPublicApiInvokeResult } from "./automationScriptApi.shared";
+
+export async function runAutomationScript(
+ input: string | AutomationScriptRunInput,
+): Promise {
+ const request = normalizeAutomationScriptRunInput(input);
+ const { launchCode, startByCodeBeforeRun, ...bindingRequest } = request;
+
+ if (startByCodeBeforeRun && launchCode) {
+ const startedProfile = await startBrowserInstanceByCode(launchCode);
+ if (!startedProfile) {
+ throw new Error(`通过 Launch Code 启动实例失败: ${launchCode}`);
+ }
+ }
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptRunWithOptions) {
+ return normalizeAutomationScriptRunRecord(
+ await bindings.AutomationScriptRunWithOptions(bindingRequest),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptRunWithOptions === "function") {
+ return normalizeAutomationScriptRunRecord(
+ await goApp.AutomationScriptRunWithOptions(bindingRequest),
+ );
+ }
+
+ if (
+ bindings?.AutomationScriptRun &&
+ bindingRequest.useScriptSelector &&
+ bindingRequest.useScriptParams
+ ) {
+ return normalizeAutomationScriptRunRecord(
+ await bindings.AutomationScriptRun(bindingRequest.scriptId),
+ );
+ }
+
+ if (
+ typeof goApp?.AutomationScriptRun === "function" &&
+ bindingRequest.useScriptSelector &&
+ bindingRequest.useScriptParams
+ ) {
+ return normalizeAutomationScriptRunRecord(
+ await goApp.AutomationScriptRun(bindingRequest.scriptId),
+ );
+ }
+
+ const now = new Date().toISOString();
+ return {
+ id: `mock-run-${Date.now()}`,
+ scriptId: bindingRequest.scriptId,
+ scriptName: "",
+ scriptType: "",
+ status: "failed",
+ summary: "当前环境未接入自动化脚本执行",
+ error: "AutomationScriptRun binding is unavailable",
+ resultText: "",
+ startedAt: now,
+ finishedAt: now,
+ durationMs: 0,
+ };
+}
+
+export async function fetchAutomationScriptRuns(
+ limit = 20,
+): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptRunList) {
+ const raw = (await bindings.AutomationScriptRunList(limit)) || [];
+ return Array.isArray(raw)
+ ? raw.map(normalizeAutomationScriptRunRecord)
+ : [];
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptRunList === "function") {
+ const raw = (await goApp.AutomationScriptRunList(limit)) || [];
+ return Array.isArray(raw)
+ ? raw.map(normalizeAutomationScriptRunRecord)
+ : [];
+ }
+
+ return [];
+}
+
+export async function invokeAutomationScriptPublicApi(
+ input: AutomationScriptPublicApiInvokeInput,
+): Promise {
+ const url = String(input?.url || "").trim();
+ if (!url) {
+ throw new Error("接口地址不能为空");
+ }
+
+ const method = String(input?.method || "POST").trim().toUpperCase() || "POST";
+ const authHeader = String(input?.authHeader || "X-Ant-Api-Key").trim() || "X-Ant-Api-Key";
+ const apiKey = String(input?.apiKey || "").trim();
+ const bodyText = String(input?.bodyText || "").trim();
+ const timeoutMs = Number.isFinite(Number(input?.timeoutMs))
+ ? Math.max(1000, Math.round(Number(input?.timeoutMs)))
+ : 0;
+
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptInvokePublicAPI) {
+ return normalizeAutomationScriptPublicApiInvokeResult(
+ await bindings.AutomationScriptInvokePublicAPI({
+ url,
+ method,
+ bodyText,
+ apiKey,
+ authHeader,
+ timeoutMs,
+ }),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptInvokePublicAPI === "function") {
+ return normalizeAutomationScriptPublicApiInvokeResult(
+ await goApp.AutomationScriptInvokePublicAPI({
+ url,
+ method,
+ bodyText,
+ apiKey,
+ authHeader,
+ timeoutMs,
+ }),
+ );
+ }
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (apiKey) {
+ headers[authHeader] = apiKey;
+ }
+
+ const response = await fetch(url, {
+ method,
+ headers,
+ body: bodyText || "{}",
+ });
+
+ const rawText = await response.text();
+ let bodyJson: unknown | null = null;
+ if (rawText.trim()) {
+ try {
+ bodyJson = JSON.parse(rawText);
+ } catch {
+ bodyJson = null;
+ }
+ }
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ bodyText: rawText,
+ bodyJson,
+ };
+}
diff --git a/frontend/src/modules/browser/automationScriptApi.scripts.ts b/frontend/src/modules/browser/automationScriptApi.scripts.ts
new file mode 100644
index 00000000..3676bfc4
--- /dev/null
+++ b/frontend/src/modules/browser/automationScriptApi.scripts.ts
@@ -0,0 +1,68 @@
+import { loadAutomationScripts, saveAutomationScripts, type AutomationScriptRecord } from "./automationScripts";
+import { getBindings, normalizeAutomationScriptRecord, sortScripts } from "./automationScriptApi.shared";
+
+export async function fetchAutomationScripts(): Promise<
+ AutomationScriptRecord[]
+> {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptList) {
+ const raw = (await bindings.AutomationScriptList()) || [];
+ return sortScripts(
+ Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [],
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptList === "function") {
+ const raw = (await goApp.AutomationScriptList()) || [];
+ return sortScripts(
+ Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [],
+ );
+ }
+
+ return loadAutomationScripts();
+}
+
+export async function saveAutomationScript(
+ script: AutomationScriptRecord,
+): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptSave) {
+ return normalizeAutomationScriptRecord(
+ await bindings.AutomationScriptSave(script),
+ );
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptSave === "function") {
+ return normalizeAutomationScriptRecord(
+ await goApp.AutomationScriptSave(script),
+ );
+ }
+
+ const current = loadAutomationScripts();
+ const next = current.some((item) => item.id === script.id)
+ ? current.map((item) => (item.id === script.id ? script : item))
+ : [script, ...current];
+ saveAutomationScripts(sortScripts(next));
+ return script;
+}
+
+export async function deleteAutomationScript(scriptId: string): Promise {
+ const bindings: any = await getBindings();
+ if (bindings?.AutomationScriptDelete) {
+ await bindings.AutomationScriptDelete(scriptId);
+ return;
+ }
+
+ const goApp = (window as any).go?.main?.App;
+ if (typeof goApp?.AutomationScriptDelete === "function") {
+ await goApp.AutomationScriptDelete(scriptId);
+ return;
+ }
+
+ saveAutomationScripts(
+ loadAutomationScripts().filter((item) => item.id !== scriptId),
+ );
+}
+
diff --git a/frontend/src/modules/browser/automationScriptApi.shared.ts b/frontend/src/modules/browser/automationScriptApi.shared.ts
new file mode 100644
index 00000000..bcf4b967
--- /dev/null
+++ b/frontend/src/modules/browser/automationScriptApi.shared.ts
@@ -0,0 +1,174 @@
+import {
+ normalizeAutomationScriptPublicAPIConfig,
+ normalizeAutomationScriptRecordPayload,
+ normalizeAutomationScriptTargetConfig,
+ type AutomationScriptRunInput,
+ type AutomationScriptRunRecord,
+ type AutomationScriptRecord,
+} from "./automationScripts";
+
+export const getBindings = async () => {
+ try {
+ return await import("../../wailsjs/go/main/App");
+ } catch {
+ return null;
+ }
+};
+
+export function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord {
+ const normalized = normalizeAutomationScriptRecordPayload(payload);
+ if (normalized) {
+ return normalized;
+ }
+
+ return {
+ packageFormat: String(payload?.packageFormat || "ant-automation-script"),
+ manifestVersion: Number(payload?.manifestVersion) || 1,
+ id: String(payload?.id || ""),
+ name: String(payload?.name || ""),
+ description: String(payload?.description || ""),
+ type: payload?.type === "launch-api" ? "launch-api" : "playwright-cdp",
+ status:
+ payload?.status === "ready" || payload?.status === "disabled"
+ ? payload.status
+ : "draft",
+ entryFile: String(payload?.entryFile || "index.cjs"),
+ tags: Array.isArray(payload?.tags)
+ ? payload.tags
+ .map((item: unknown) => String(item || "").trim())
+ .filter(Boolean)
+ : [],
+ selectorText: String(payload?.selectorText || ""),
+ paramsText: String(payload?.paramsText || ""),
+ scriptText: String(payload?.scriptText || ""),
+ notes: String(payload?.notes || ""),
+ targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig),
+ publicAPI: normalizeAutomationScriptPublicAPIConfig(payload?.publicAPI),
+ source: {
+ type: String(payload?.source?.type || ""),
+ uri: String(payload?.source?.uri || ""),
+ ref: String(payload?.source?.ref || ""),
+ path: String(payload?.source?.path || ""),
+ importedAt: String(payload?.source?.importedAt || ""),
+ },
+ createdAt: String(payload?.createdAt || ""),
+ updatedAt: String(payload?.updatedAt || ""),
+ };
+}
+
+export function sortScripts(
+ items: AutomationScriptRecord[],
+): AutomationScriptRecord[] {
+ return [...items].sort(
+ (left, right) =>
+ new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
+ );
+}
+
+export function normalizeAutomationScriptRunRecord(
+ payload: any,
+): AutomationScriptRunRecord {
+ return {
+ id: String(payload?.id || ""),
+ scriptId: String(payload?.scriptId || ""),
+ scriptName: String(payload?.scriptName || ""),
+ scriptType: String(payload?.scriptType || ""),
+ status:
+ payload?.status === "success" || payload?.status === "running"
+ ? payload.status
+ : "failed",
+ summary: String(payload?.summary || ""),
+ error: String(payload?.error || ""),
+ resultText: String(payload?.resultText || ""),
+ startedAt: String(payload?.startedAt || ""),
+ finishedAt: String(payload?.finishedAt || ""),
+ durationMs: Number(payload?.durationMs) || 0,
+ };
+}
+
+export interface AutomationScriptExportResult {
+ cancelled: boolean;
+ format: string;
+ message: string;
+ path: string;
+ fileCount: number;
+}
+
+export interface AutomationScriptImportIssue {
+ path: string;
+ message: string;
+}
+
+export interface AutomationScriptBatchImportResult {
+ imported: AutomationScriptRecord[];
+ failed: AutomationScriptImportIssue[];
+ scanned: number;
+}
+
+export interface AutomationScriptPublicApiInvokeInput {
+ url: string;
+ method?: string;
+ bodyText?: string;
+ apiKey?: string;
+ authHeader?: string;
+ timeoutMs?: number;
+}
+
+export interface AutomationScriptPublicApiInvokeResult {
+ ok: boolean;
+ status: number;
+ statusText: string;
+ bodyText: string;
+ bodyJson: unknown | null;
+}
+
+export function normalizeAutomationScriptPublicApiInvokeResult(
+ payload: any,
+): AutomationScriptPublicApiInvokeResult {
+ return {
+ ok: payload?.ok === true,
+ status: Number(payload?.status) || 0,
+ statusText: String(payload?.statusText || ""),
+ bodyText: String(payload?.bodyText || ""),
+ bodyJson: payload?.bodyJson ?? null,
+ };
+}
+
+export function normalizeAutomationScriptRunInput(
+ input: string | AutomationScriptRunInput,
+): AutomationScriptRunInput {
+ if (typeof input === "string") {
+ return {
+ scriptId: input,
+ selectorText: "",
+ targetInput: {},
+ paramsText: "",
+ useScriptSelector: true,
+ useScriptParams: true,
+ timeoutMs: 0,
+ launchCode: "",
+ startByCodeBeforeRun: false,
+ };
+ }
+
+ return {
+ scriptId: String(input?.scriptId || ""),
+ selectorText: String(input?.selectorText || ""),
+ targetInput:
+ input?.targetInput && typeof input.targetInput === "object"
+ ? { ...input.targetInput }
+ : {},
+ paramsText: String(input?.paramsText || ""),
+ useScriptSelector: input?.useScriptSelector !== false,
+ useScriptParams: input?.useScriptParams !== false,
+ timeoutMs: Number.isFinite(Number(input?.timeoutMs))
+ ? Math.round(Number(input?.timeoutMs))
+ : 0,
+ launchCode: String(input?.launchCode || "")
+ .trim()
+ .toUpperCase(),
+ startByCodeBeforeRun: input?.startByCodeBeforeRun === true,
+ };
+}
+
+
diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts
index d3af2d70..69822105 100644
--- a/frontend/src/modules/browser/automationScriptApi.ts
+++ b/frontend/src/modules/browser/automationScriptApi.ts
@@ -1,720 +1,11 @@
-import {
- exportAutomationScript,
- importAutomationScript,
- loadAutomationScripts,
- normalizeAutomationScriptPublicAPIConfig,
- normalizeAutomationScriptRecordPayload,
- normalizeAutomationScriptTargetConfig,
- saveAutomationScripts,
- type AutomationScriptRunInput,
- type AutomationScriptRunRecord,
- type AutomationScriptRecord,
-} from "./automationScripts";
-import { startBrowserInstanceByCode } from "./api/instances";
-
-const getBindings = async () => {
- try {
- return await import("../../wailsjs/go/main/App");
- } catch {
- return null;
- }
-};
-
-function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord {
- const normalized = normalizeAutomationScriptRecordPayload(payload);
- if (normalized) {
- return normalized;
- }
-
- return {
- packageFormat: String(payload?.packageFormat || "ant-automation-script"),
- manifestVersion: Number(payload?.manifestVersion) || 1,
- id: String(payload?.id || ""),
- name: String(payload?.name || ""),
- description: String(payload?.description || ""),
- type: payload?.type === "launch-api" ? "launch-api" : "playwright-cdp",
- status:
- payload?.status === "ready" || payload?.status === "disabled"
- ? payload.status
- : "draft",
- entryFile: String(payload?.entryFile || "index.cjs"),
- tags: Array.isArray(payload?.tags)
- ? payload.tags
- .map((item: unknown) => String(item || "").trim())
- .filter(Boolean)
- : [],
- selectorText: String(payload?.selectorText || ""),
- paramsText: String(payload?.paramsText || ""),
- scriptText: String(payload?.scriptText || ""),
- notes: String(payload?.notes || ""),
- targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig),
- publicAPI: normalizeAutomationScriptPublicAPIConfig(payload?.publicAPI),
- source: {
- type: String(payload?.source?.type || ""),
- uri: String(payload?.source?.uri || ""),
- ref: String(payload?.source?.ref || ""),
- path: String(payload?.source?.path || ""),
- importedAt: String(payload?.source?.importedAt || ""),
- },
- createdAt: String(payload?.createdAt || ""),
- updatedAt: String(payload?.updatedAt || ""),
- };
-}
-
-function sortScripts(
- items: AutomationScriptRecord[],
-): AutomationScriptRecord[] {
- return [...items].sort(
- (left, right) =>
- new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
- );
-}
-
-function normalizeAutomationScriptRunRecord(
- payload: any,
-): AutomationScriptRunRecord {
- return {
- id: String(payload?.id || ""),
- scriptId: String(payload?.scriptId || ""),
- scriptName: String(payload?.scriptName || ""),
- scriptType: String(payload?.scriptType || ""),
- status:
- payload?.status === "success" || payload?.status === "running"
- ? payload.status
- : "failed",
- summary: String(payload?.summary || ""),
- error: String(payload?.error || ""),
- resultText: String(payload?.resultText || ""),
- startedAt: String(payload?.startedAt || ""),
- finishedAt: String(payload?.finishedAt || ""),
- durationMs: Number(payload?.durationMs) || 0,
- };
-}
-
-export interface AutomationScriptExportResult {
- cancelled: boolean;
- format: string;
- message: string;
- path: string;
- fileCount: number;
-}
-
-export interface AutomationScriptImportIssue {
- path: string;
- message: string;
-}
-
-export interface AutomationScriptBatchImportResult {
- imported: AutomationScriptRecord[];
- failed: AutomationScriptImportIssue[];
- scanned: number;
-}
-
-export interface AutomationScriptPublicApiInvokeInput {
- url: string;
- method?: string;
- bodyText?: string;
- apiKey?: string;
- authHeader?: string;
- timeoutMs?: number;
-}
-
-export interface AutomationScriptPublicApiInvokeResult {
- ok: boolean;
- status: number;
- statusText: string;
- bodyText: string;
- bodyJson: unknown | null;
-}
-
-function normalizeAutomationScriptPublicApiInvokeResult(
- payload: any,
-): AutomationScriptPublicApiInvokeResult {
- return {
- ok: payload?.ok === true,
- status: Number(payload?.status) || 0,
- statusText: String(payload?.statusText || ""),
- bodyText: String(payload?.bodyText || ""),
- bodyJson: payload?.bodyJson ?? null,
- };
-}
-
-function normalizeAutomationScriptRunInput(
- input: string | AutomationScriptRunInput,
-): AutomationScriptRunInput {
- if (typeof input === "string") {
- return {
- scriptId: input,
- selectorText: "",
- targetInput: {},
- paramsText: "",
- useScriptSelector: true,
- useScriptParams: true,
- timeoutMs: 0,
- launchCode: "",
- startByCodeBeforeRun: false,
- };
- }
-
- return {
- scriptId: String(input?.scriptId || ""),
- selectorText: String(input?.selectorText || ""),
- targetInput:
- input?.targetInput && typeof input.targetInput === "object"
- ? { ...input.targetInput }
- : {},
- paramsText: String(input?.paramsText || ""),
- useScriptSelector: input?.useScriptSelector !== false,
- useScriptParams: input?.useScriptParams !== false,
- timeoutMs: Number.isFinite(Number(input?.timeoutMs))
- ? Math.round(Number(input?.timeoutMs))
- : 0,
- launchCode: String(input?.launchCode || "")
- .trim()
- .toUpperCase(),
- startByCodeBeforeRun: input?.startByCodeBeforeRun === true,
- };
-}
-
-export async function fetchAutomationScripts(): Promise<
- AutomationScriptRecord[]
-> {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptList) {
- const raw = (await bindings.AutomationScriptList()) || [];
- return sortScripts(
- Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [],
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptList === "function") {
- const raw = (await goApp.AutomationScriptList()) || [];
- return sortScripts(
- Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [],
- );
- }
-
- return loadAutomationScripts();
-}
-
-export async function saveAutomationScript(
- script: AutomationScriptRecord,
-): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptSave) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptSave(script),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptSave === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptSave(script),
- );
- }
-
- const current = loadAutomationScripts();
- const next = current.some((item) => item.id === script.id)
- ? current.map((item) => (item.id === script.id ? script : item))
- : [script, ...current];
- saveAutomationScripts(sortScripts(next));
- return script;
-}
-
-export async function deleteAutomationScript(scriptId: string): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptDelete) {
- await bindings.AutomationScriptDelete(scriptId);
- return;
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptDelete === "function") {
- await goApp.AutomationScriptDelete(scriptId);
- return;
- }
-
- saveAutomationScripts(
- loadAutomationScripts().filter((item) => item.id !== scriptId),
- );
-}
-
-export async function importAutomationScriptFromLocalFile(): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportLocalFile) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptImportLocalFile(),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportLocalFile === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptImportLocalFile(),
- );
- }
-
- throw new Error("当前环境不支持本地文件导入");
-}
-
-export async function importAutomationScriptFromText(
- text: string,
-): Promise {
- const normalizedText = String(text || "").trim();
- if (!normalizedText) {
- throw new Error("导入内容不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportText) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptImportText(normalizedText),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportText === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptImportText(normalizedText),
- );
- }
-
- return importAutomationScript(normalizedText);
-}
-
-export async function importAutomationScriptFromLocalDirectory(): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportLocalDirectory) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptImportLocalDirectory(),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportLocalDirectory === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptImportLocalDirectory(),
- );
- }
-
- throw new Error("当前环境不支持本地目录导入");
-}
-
-function normalizeAutomationScriptBatchImportResult(
- payload: any,
-): AutomationScriptBatchImportResult {
- const imported = Array.isArray(payload?.imported)
- ? payload.imported.map(normalizeAutomationScriptRecord)
- : [];
-
- return {
- imported,
- failed: Array.isArray(payload?.failed)
- ? payload.failed.map((item: any) => ({
- path: String(item?.path || ""),
- message: String(item?.message || ""),
- }))
- : [],
- scanned:
- Number.isFinite(Number(payload?.scanned)) && Number(payload.scanned) > 0
- ? Math.round(Number(payload.scanned))
- : imported.length,
- };
-}
-
-export async function importAutomationScriptFromLocalLibrary(): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportLocalLibrary) {
- return normalizeAutomationScriptBatchImportResult(
- await bindings.AutomationScriptImportLocalLibrary(),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportLocalLibrary === "function") {
- return normalizeAutomationScriptBatchImportResult(
- await goApp.AutomationScriptImportLocalLibrary(),
- );
- }
-
- throw new Error("当前环境不支持本地脚本库导入");
-}
-
-export async function importAutomationScriptFromRemote(url: string): Promise {
- const normalizedURL = String(url || "").trim();
- if (!normalizedURL) {
- throw new Error("远程脚本地址不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportRemote) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptImportRemote(normalizedURL),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportRemote === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptImportRemote(normalizedURL),
- );
- }
-
- throw new Error("当前环境不支持远程脚本导入");
-}
-
-export async function importAutomationScriptFromGit(
- repoURL: string,
- ref = "",
- scriptPath = "",
-): Promise {
- const normalizedRepoURL = String(repoURL || "").trim();
- if (!normalizedRepoURL) {
- throw new Error("Git 仓库地址不能为空");
- }
-
- const normalizedRef = String(ref || "").trim();
- const normalizedScriptPath = String(scriptPath || "").trim();
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptImportGit) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptImportGit(
- normalizedRepoURL,
- normalizedRef,
- normalizedScriptPath,
- ),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptImportGit === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptImportGit(
- normalizedRepoURL,
- normalizedRef,
- normalizedScriptPath,
- ),
- );
- }
-
- throw new Error("当前环境不支持 Git 脚本导入");
-}
-
-export async function refreshAutomationScript(
- scriptId: string,
-): Promise {
- const normalizedScriptId = String(scriptId || "").trim();
- if (!normalizedScriptId) {
- throw new Error("脚本 ID 不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptRefresh) {
- return normalizeAutomationScriptRecord(
- await bindings.AutomationScriptRefresh(normalizedScriptId),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptRefresh === "function") {
- return normalizeAutomationScriptRecord(
- await goApp.AutomationScriptRefresh(normalizedScriptId),
- );
- }
-
- throw new Error("当前环境不支持按来源重新导入");
-}
-
-function normalizeAutomationScriptExportResult(
- payload: any,
-): AutomationScriptExportResult {
- return {
- cancelled: payload?.cancelled === true,
- format: String(payload?.format || ""),
- message: String(payload?.message || ""),
- path: String(payload?.path || ""),
- fileCount: Number(payload?.fileCount) || 0,
- };
-}
-
-function buildAutomationTemplateFallbackFilename(script: AutomationScriptRecord): string {
- const normalizedName = String(script.name || "")
- .trim()
- .replace(/[\\/:*?"<>|]+/g, "-")
- .replace(/\s+/g, "-")
- .replace(/^-+|-+$/g, "");
-
- return `${normalizedName || "automation-script"}-template.json`;
-}
-
-function downloadAutomationTemplate(
- filename: string,
- content: string,
-): AutomationScriptExportResult {
- const blob = new Blob([content], { type: "application/json;charset=utf-8" });
- const url = URL.createObjectURL(blob);
-
- try {
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = filename;
- anchor.click();
- } finally {
- URL.revokeObjectURL(url);
- }
-
- return {
- cancelled: false,
- format: "json",
- message: "模板已导出",
- path: filename,
- fileCount: 1,
- };
-}
-
-export async function exportAutomationScriptTemplate(
- scriptId: string,
- fallbackScript?: AutomationScriptRecord,
-): Promise {
- const normalizedScriptId = String(scriptId || "").trim();
- if (!normalizedScriptId) {
- throw new Error("脚本 ID 不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptExport) {
- return normalizeAutomationScriptExportResult(
- await bindings.AutomationScriptExport(normalizedScriptId),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptExport === "function") {
- return normalizeAutomationScriptExportResult(
- await goApp.AutomationScriptExport(normalizedScriptId),
- );
- }
-
- if (fallbackScript && typeof document !== "undefined") {
- return downloadAutomationTemplate(
- buildAutomationTemplateFallbackFilename(fallbackScript),
- exportAutomationScript(fallbackScript),
- );
- }
-
- throw new Error("当前环境不支持脚本模板导出");
-}
-
-export async function exportAutomationScriptZip(
- scriptId: string,
-): Promise {
- const normalizedScriptId = String(scriptId || "").trim();
- if (!normalizedScriptId) {
- throw new Error("脚本 ID 不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptExportZip) {
- return normalizeAutomationScriptExportResult(
- await bindings.AutomationScriptExportZip(normalizedScriptId),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptExportZip === "function") {
- return normalizeAutomationScriptExportResult(
- await goApp.AutomationScriptExportZip(normalizedScriptId),
- );
- }
-
- throw new Error("当前环境不支持 ZIP 脚本包导出");
-}
-
-export async function exportAutomationScriptDirectory(
- scriptId: string,
-): Promise {
- const normalizedScriptId = String(scriptId || "").trim();
- if (!normalizedScriptId) {
- throw new Error("脚本 ID 不能为空");
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptExportDirectory) {
- return normalizeAutomationScriptExportResult(
- await bindings.AutomationScriptExportDirectory(normalizedScriptId),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptExportDirectory === "function") {
- return normalizeAutomationScriptExportResult(
- await goApp.AutomationScriptExportDirectory(normalizedScriptId),
- );
- }
-
- throw new Error("当前环境不支持目录脚本包导出");
-}
-
-export async function runAutomationScript(
- input: string | AutomationScriptRunInput,
-): Promise {
- const request = normalizeAutomationScriptRunInput(input);
- const { launchCode, startByCodeBeforeRun, ...bindingRequest } = request;
-
- if (startByCodeBeforeRun && launchCode) {
- const startedProfile = await startBrowserInstanceByCode(launchCode);
- if (!startedProfile) {
- throw new Error(`通过 Launch Code 启动实例失败: ${launchCode}`);
- }
- }
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptRunWithOptions) {
- return normalizeAutomationScriptRunRecord(
- await bindings.AutomationScriptRunWithOptions(bindingRequest),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptRunWithOptions === "function") {
- return normalizeAutomationScriptRunRecord(
- await goApp.AutomationScriptRunWithOptions(bindingRequest),
- );
- }
-
- if (
- bindings?.AutomationScriptRun &&
- bindingRequest.useScriptSelector &&
- bindingRequest.useScriptParams
- ) {
- return normalizeAutomationScriptRunRecord(
- await bindings.AutomationScriptRun(bindingRequest.scriptId),
- );
- }
-
- if (
- typeof goApp?.AutomationScriptRun === "function" &&
- bindingRequest.useScriptSelector &&
- bindingRequest.useScriptParams
- ) {
- return normalizeAutomationScriptRunRecord(
- await goApp.AutomationScriptRun(bindingRequest.scriptId),
- );
- }
-
- const now = new Date().toISOString();
- return {
- id: `mock-run-${Date.now()}`,
- scriptId: bindingRequest.scriptId,
- scriptName: "",
- scriptType: "",
- status: "failed",
- summary: "当前环境未接入自动化脚本执行",
- error: "AutomationScriptRun binding is unavailable",
- resultText: "",
- startedAt: now,
- finishedAt: now,
- durationMs: 0,
- };
-}
-
-export async function fetchAutomationScriptRuns(
- limit = 20,
-): Promise {
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptRunList) {
- const raw = (await bindings.AutomationScriptRunList(limit)) || [];
- return Array.isArray(raw)
- ? raw.map(normalizeAutomationScriptRunRecord)
- : [];
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptRunList === "function") {
- const raw = (await goApp.AutomationScriptRunList(limit)) || [];
- return Array.isArray(raw)
- ? raw.map(normalizeAutomationScriptRunRecord)
- : [];
- }
-
- return [];
-}
-
-export async function invokeAutomationScriptPublicApi(
- input: AutomationScriptPublicApiInvokeInput,
-): Promise {
- const url = String(input?.url || "").trim();
- if (!url) {
- throw new Error("接口地址不能为空");
- }
-
- const method = String(input?.method || "POST").trim().toUpperCase() || "POST";
- const authHeader = String(input?.authHeader || "X-Ant-Api-Key").trim() || "X-Ant-Api-Key";
- const apiKey = String(input?.apiKey || "").trim();
- const bodyText = String(input?.bodyText || "").trim();
- const timeoutMs = Number.isFinite(Number(input?.timeoutMs))
- ? Math.max(1000, Math.round(Number(input?.timeoutMs)))
- : 0;
-
- const bindings: any = await getBindings();
- if (bindings?.AutomationScriptInvokePublicAPI) {
- return normalizeAutomationScriptPublicApiInvokeResult(
- await bindings.AutomationScriptInvokePublicAPI({
- url,
- method,
- bodyText,
- apiKey,
- authHeader,
- timeoutMs,
- }),
- );
- }
-
- const goApp = (window as any).go?.main?.App;
- if (typeof goApp?.AutomationScriptInvokePublicAPI === "function") {
- return normalizeAutomationScriptPublicApiInvokeResult(
- await goApp.AutomationScriptInvokePublicAPI({
- url,
- method,
- bodyText,
- apiKey,
- authHeader,
- timeoutMs,
- }),
- );
- }
-
- const headers: Record = {
- "Content-Type": "application/json",
- };
- if (apiKey) {
- headers[authHeader] = apiKey;
- }
-
- const response = await fetch(url, {
- method,
- headers,
- body: bodyText || "{}",
- });
-
- const rawText = await response.text();
- let bodyJson: unknown | null = null;
- if (rawText.trim()) {
- try {
- bodyJson = JSON.parse(rawText);
- } catch {
- bodyJson = null;
- }
- }
-
- return {
- ok: response.ok,
- status: response.status,
- statusText: response.statusText,
- bodyText: rawText,
- bodyJson,
- };
-}
+export type {
+ AutomationScriptBatchImportResult,
+ AutomationScriptExportResult,
+ AutomationScriptImportIssue,
+ AutomationScriptPublicApiInvokeInput,
+ AutomationScriptPublicApiInvokeResult,
+} from "./automationScriptApi.shared";
+export { fetchAutomationScripts, saveAutomationScript, deleteAutomationScript } from "./automationScriptApi.scripts";
+export { importAutomationScriptFromGit, importAutomationScriptFromLocalDirectory, importAutomationScriptFromLocalFile, importAutomationScriptFromLocalLibrary, importAutomationScriptFromRemote, importAutomationScriptFromText, refreshAutomationScript } from "./automationScriptApi.imports";
+export { exportAutomationScriptDirectory, exportAutomationScriptTemplate, exportAutomationScriptZip } from "./automationScriptApi.exports";
+export { fetchAutomationScriptRuns, invokeAutomationScriptPublicApi, runAutomationScript } from "./automationScriptApi.runs";
diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts
index 77076f7c..b215def4 100644
--- a/frontend/src/modules/browser/automationScripts.ts
+++ b/frontend/src/modules/browser/automationScripts.ts
@@ -44,6 +44,13 @@ export {
resolveAutomationScriptPublicAPIConfig,
suggestAutomationScriptPublicAPIPath,
} from "./automationScripts/publicApi";
+export {
+ buildAutomationScriptPublicAPIRequestBodyWithTargetCode,
+ normalizeAutomationScriptPublicAPIRequestBodyForInvoke,
+ readAutomationScriptPublicAPIInstanceType,
+ readAutomationScriptPublicAPIParamObject,
+ readAutomationScriptPublicAPITargetCode,
+} from "./automationScripts/publicApiInstances";
export {
canRefreshAutomationScriptSource,
getAutomationScriptRefreshLabel,
diff --git a/frontend/src/modules/browser/automationScripts/builtins.ts b/frontend/src/modules/browser/automationScripts/builtins.ts
index 1f837b73..88f51f63 100644
--- a/frontend/src/modules/browser/automationScripts/builtins.ts
+++ b/frontend/src/modules/browser/automationScripts/builtins.ts
@@ -3,416 +3,32 @@
AUTOMATION_SCRIPT_PACKAGE_FORMAT,
DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
type AutomationScriptRecord,
- type AutomationScriptType,
} from "./definitions";
import { createAutomationScriptPublicAPIConfig } from "./publicApi";
import {
normalizeAutomationScriptTargetConfig,
- normalizeAutomationScriptTargetSelector,
} from "./targets";
const BACKEND_BUILTIN_SCRIPT_PLACEHOLDER = `module.exports.run = async () => {
throw new Error('内置脚本源码由后端 demo-library 提供,请在桌面应用后端环境中加载或从脚本包导入。')
}`;
-const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const;
-const DUAL_INSTANCE_DEFAULT_START_URLS = [
- "https://finance.sina.com.cn/",
- "https://map.baidu.com/",
-] as const;
function nowIso(): string {
return new Date().toISOString();
}
-export function buildSelectorTemplate(type: AutomationScriptType): string {
- if (type === "launch-api") {
- return `{
- "code": "BUYER_001"
-}`;
- }
-
- return "";
-}
-
-export function buildParamsTemplate(type: AutomationScriptType): string {
- if (type === "launch-api") {
- return `{
- "startUrls": ["https://example.com"],
- "skipDefaultStartUrls": true
-}`;
- }
-
- return `{
- "url": "https://www.baidu.com",
- "keyword": "OpenAI",
- "timeoutMs": 30000,
- "waitAfterSearchMs": 1500,
- "captureScreenshot": true
-}`;
-}
-
-export function buildScriptTemplate(type: AutomationScriptType): string {
- if (type === "launch-api") {
- return `export async function run({ baseUrl, apiKey, selector, params }) {
- const response = await fetch(\`\${baseUrl}/api/launch\`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}),
- },
- body: JSON.stringify({
- selector,
- ...(params || {}),
- }),
- })
-
- if (!response.ok) {
- throw new Error(\`launch failed: \${response.status}\`)
- }
-
- return await response.json()
-}`;
- }
-
- return `module.exports.run = async ({ useBrowser, browserFetch, selector, params, log, artifact }) => {
- const targetUrl =
- typeof params.url === 'string' && params.url.trim()
- ? params.url.trim()
- : 'https://www.baidu.com'
- const keyword =
- typeof params.keyword === 'string' && params.keyword.trim()
- ? params.keyword.trim()
- : 'OpenAI'
- const timeout =
- Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0
- ? Math.round(Number(params.timeoutMs))
- : 30000
- const waitAfterSearchMs =
- Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0
- ? Math.round(Number(params.waitAfterSearchMs))
- : 1500
-
- const runtime = await useBrowser({
- selector,
- startUrls: params.startUrls || [targetUrl],
- skipDefaultStartUrls: true,
- url: targetUrl,
- timeoutMs: timeout,
- reuseCurrentPage: true,
- })
- const page = runtime.page
-
- const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first()
- await searchInput.waitFor({
- state: 'visible',
- timeout,
- })
- await searchInput.fill(keyword)
- await searchInput.press('Enter').catch(async () => {
- const submitButton = page.locator('#su, input[type="submit"]').first()
- await submitButton.click({ timeout })
- })
- await page.waitForURL(/wd=/, { timeout }).catch(() => {})
-
- if (waitAfterSearchMs > 0) {
- await page.waitForTimeout(waitAfterSearchMs)
- }
-
- if (params.captureScreenshot !== false) {
- await page.screenshot({
- path: artifact('baidu-search.png'),
- fullPage: true,
- })
- }
-
- const title = await page.title()
- let apiResult = null
- const apiUrl = typeof params.apiUrl === 'string' ? params.apiUrl.trim() : ''
- if (apiUrl) {
- const apiRequest = {
- url: apiUrl,
- method: params.apiBody === undefined ? 'GET' : 'POST',
- timeoutMs: timeout,
- }
- if (params.apiBody !== undefined) {
- apiRequest.json = params.apiBody
- }
- apiResult = await browserFetch(page, apiRequest)
- }
- log('keyword', keyword)
- log('title', title)
-
- return {
- ok: true,
- summary: \`已在百度搜索 \${keyword}\`,
- keyword,
- url: page.url(),
- title,
- apiResult,
- }
-}`;
-}
-
-export function buildNotesTemplate(type: AutomationScriptType): string {
- if (type === "launch-api") {
- return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。";
- }
-
- return "默认示例使用 useBrowser 启动并接管页面;需要调用站内接口时传 apiUrl/apiBody,会通过 browserFetch 在浏览器上下文发起请求。";
-}
-
-function buildDualInstanceRuntimeParamsText(
- codes = [...DUAL_INSTANCE_DEFAULT_CODES],
-): string {
- return `{
- "browsers": [
- {
- "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}",
- "skipDefaultStartUrls": true,
- "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"]
- },
- {
- "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}",
- "skipDefaultStartUrls": true,
- "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"]
- }
- ],
- "timeoutMs": 45000
-}`;
-}
-
-function buildDualInstanceRuntimeScriptText(): string {
- return `export async function run({ baseUrl, apiKey, params, log }) {
- const normalizeCode = (value, fallback) =>
- String(value || fallback || "").trim().toUpperCase()
- const normalizeStringArray = (value) =>
- Array.isArray(value)
- ? value
- .map((item) => String(item || "").trim())
- .filter(Boolean)
- : []
- const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => {
- const raw = value && typeof value === "object" ? value : {}
- const code = normalizeCode(raw.code || raw.launchCode, fallbackCode)
- if (!code) {
- return null
- }
- const startUrls = normalizeStringArray(raw.startUrls)
- const fallbackUrls = normalizeStringArray(fallbackStartUrls)
- const launchArgs = normalizeStringArray(raw.launchArgs)
-
- return {
- code,
- skipDefaultStartUrls:
- raw.skipDefaultStartUrls !== undefined
- ? raw.skipDefaultStartUrls !== false
- : defaultSkip,
- startUrls: startUrls.length > 0 ? startUrls : fallbackUrls,
- launchArgs,
- }
- }
-
- const timeoutMs = Number.isFinite(Number(params.timeoutMs))
- ? Math.max(1000, Math.round(Number(params.timeoutMs)))
- : 45000
- const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false
-
- let browsers = Array.isArray(params.browsers)
- ? params.browsers
- .map((item, index) =>
- normalizeBrowserInput(
- item,
- ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "",
- ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [],
- defaultSkipDefaultStartUrls,
- ),
- )
- .filter(Boolean)
- : []
-
- if (browsers.length === 0) {
- browsers = [
- normalizeBrowserInput(
- { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
- ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])},
- ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])},
- defaultSkipDefaultStartUrls,
- ),
- normalizeBrowserInput(
- { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
- ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])},
- ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])},
- defaultSkipDefaultStartUrls,
- ),
- ].filter(Boolean)
- }
-
- if (browsers.length === 0) {
- throw new Error("params.browsers 不能为空")
- }
-
- const headers = {
- "Content-Type": "application/json",
- ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}),
- }
-
- const post = async (path, payload) => {
- const response = await fetch(\`\${baseUrl}\${path}\`, {
- method: "POST",
- headers,
- body: JSON.stringify(payload),
- })
- const text = await response.text()
- let body = text
- try {
- body = text ? JSON.parse(text) : null
- } catch {
- body = text
- }
- if (!response.ok) {
- throw new Error(\`\${path} failed: \${response.status} \${text}\`)
- }
- return body
- }
-
- const sessions = []
-
- for (const browser of browsers) {
- const sessionResult = await post("/api/runtime/session", {
- selector: { code: browser.code, matchMode: "unique" },
- skipDefaultStartUrls: browser.skipDefaultStartUrls,
- ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}),
- ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}),
- timeoutMs,
- })
-
- sessions.push(sessionResult)
- }
-
- const browserCodes = browsers.map((item) => item.code)
- log("browserCodes", browserCodes)
-
- return {
- ok: true,
- summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`,
- browserCodes,
- sessions,
- }
-}`;
-}
-
-export function normalizeDualInstanceRuntimeParamsText(text: string): string {
- const fallback = buildDualInstanceRuntimeParamsText();
-
- try {
- const parsed = JSON.parse(text);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return fallback;
- }
-
- const raw = parsed as Record;
- const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false;
- const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : [];
- const browsers = rawBrowsers
- .map((item, index) => {
- if (!item || typeof item !== "object") {
- return null;
- }
- const entry = item as Record;
- const code = normalizeAutomationScriptTargetSelector({
- code:
- typeof entry.code === "string"
- ? entry.code
- : typeof entry.launchCode === "string"
- ? entry.launchCode
- : "",
- }).code;
- if (!code) {
- return null;
- }
-
- const startUrls = Array.isArray(entry.startUrls)
- ? entry.startUrls
- .map((value) => String(value || "").trim())
- .filter(Boolean)
- : [];
- const launchArgs = Array.isArray(entry.launchArgs)
- ? entry.launchArgs
- .map((value) => String(value || "").trim())
- .filter(Boolean)
- : [];
-
- const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index]
- ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
- : [];
-
- return {
- code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "",
- skipDefaultStartUrls:
- entry.skipDefaultStartUrls !== undefined
- ? entry.skipDefaultStartUrls !== false
- : topLevelSkipDefaultStartUrls,
- startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls,
- ...(launchArgs.length > 0 ? { launchArgs } : {}),
- };
- })
- .filter(
- (
- item,
- ): item is {
- code: string;
- skipDefaultStartUrls: boolean;
- startUrls: string[];
- launchArgs?: string[];
- } => item !== null,
- );
-
- const legacyCodes = [
- normalizeAutomationScriptTargetSelector({
- code: typeof raw.primaryCode === "string" ? raw.primaryCode : "",
- }).code,
- normalizeAutomationScriptTargetSelector({
- code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "",
- }).code,
- ].filter(Boolean);
-
- const normalizedBrowsers =
- browsers.length > 0
- ? browsers
- : legacyCodes.length > 0
- ? legacyCodes.map((code, index) => ({
- code,
- skipDefaultStartUrls: topLevelSkipDefaultStartUrls,
- startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index]
- ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
- : [],
- }))
- : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({
- code,
- skipDefaultStartUrls: true,
- startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index]
- ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
- : [],
- }));
-
- const timeoutMs =
- Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0
- ? Math.round(Number(raw.timeoutMs))
- : 45000;
-
- return JSON.stringify(
- {
- browsers: normalizedBrowsers,
- timeoutMs,
- },
- null,
- 2,
- );
- } catch {
- return fallback;
- }
-}
+export {
+ buildParamsTemplate,
+ buildScriptTemplate,
+ buildSelectorTemplate,
+ buildNotesTemplate,
+ normalizeDualInstanceRuntimeParamsText,
+} from "./builtinsTemplates";
+import {
+ buildDualInstanceRuntimeParamsText,
+ buildDualInstanceRuntimeScriptText,
+} from "./builtinsTemplates";
export function createNewsTxtScriptDraft(): AutomationScriptRecord {
const createdAt = nowIso();
@@ -496,31 +112,18 @@ export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRec
id: "web-image-generate-download",
name: "网页图片生成并下载",
description:
- "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。",
+ "打开 ChatGPT,发送图片生成消息,等待图片生成后下载图片。",
type: "playwright-cdp",
status: "draft",
entryFile: "index.cjs",
- tags: ["Playwright", "图片生成", "下载", "脚手架"],
+ tags: ["Playwright", "图片生成", "下载"],
selectorText: "",
paramsText: `{
- "pageUrl": "https://chatgpt.com/",
- "prompt": "A cinematic chrome ant browser mascot, premium product lighting",
- "outputFileName": "generated-image.png",
- "selectors": {
- "newSessionButton": "",
- "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]",
- "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color",
- "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]",
- "downloadButton": ""
- },
- "timeoutMs": 300000,
- "waitAfterLoadMs": 1200,
- "settleMs": 2500,
- "captureScreenshot": false
+ "prompt": "A cinematic chrome ant browser mascot, premium product lighting"
}`,
scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER,
notes:
- "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。",
+ "脚本默认打开 ChatGPT,输入图片生成提示词并发送;页面选择器、下载文件名等由脚本内部默认值处理,公开接口只需要传实例、提示词和超时时间。",
targetConfig: normalizeAutomationScriptTargetConfig(null),
publicAPI: {
...createAutomationScriptPublicAPIConfig(),
@@ -528,12 +131,21 @@ export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRec
path: "image/chatgpt-generate-download",
timeoutMs: 300000,
requestBodyText: `{
+ "instance": {
+ "type": "existing",
+ "selector": {
+ "code": "BUYER_001"
+ }
+ },
"params": {
"prompt": "{{prompt}}"
- }
+ },
+ "timeoutMs": 300000
}`,
responseBodyText: `{
"ok": true,
+ "status": "completed",
+ "summary": "图片已生成并下载。",
"outputPath": "\${artifactsDir}/generated-image.png",
"downloadAddress": "\${artifactsDir}/generated-image.png"
}`,
@@ -566,3 +178,4 @@ export function buildDefaultAutomationScripts(): AutomationScriptRecord[] {
createWebImageGenerateDownloadScriptDraft(),
];
}
+
diff --git a/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts b/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts
new file mode 100644
index 00000000..3589eb2c
--- /dev/null
+++ b/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts
@@ -0,0 +1,399 @@
+import type { AutomationScriptType } from './definitions'
+import { normalizeAutomationScriptTargetSelector } from './targets'
+
+const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const;
+const DUAL_INSTANCE_DEFAULT_START_URLS = [
+ "https://finance.sina.com.cn/",
+ "https://map.baidu.com/",
+] as const;
+
+export function buildSelectorTemplate(type: AutomationScriptType): string {
+ if (type === "launch-api") {
+ return `{
+ "code": "BUYER_001"
+}`;
+ }
+
+ return "";
+}
+
+export function buildParamsTemplate(type: AutomationScriptType): string {
+ if (type === "launch-api") {
+ return `{
+ "startUrls": ["https://example.com"],
+ "skipDefaultStartUrls": true
+}`;
+ }
+
+ return `{
+ "url": "https://www.baidu.com",
+ "keyword": "OpenAI",
+ "timeoutMs": 30000,
+ "waitAfterSearchMs": 1500,
+ "captureScreenshot": true
+}`;
+}
+
+export function buildScriptTemplate(type: AutomationScriptType): string {
+ if (type === "launch-api") {
+ return `export async function run({ baseUrl, apiKey, selector, params }) {
+ const response = await fetch(\`\${baseUrl}/api/launch\`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}),
+ },
+ body: JSON.stringify({
+ selector,
+ ...(params || {}),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(\`launch failed: \${response.status}\`)
+ }
+
+ return await response.json()
+}`;
+ }
+
+ return `module.exports.run = async ({ useBrowser, browserFetch, selector, params, log, artifact }) => {
+ const targetUrl =
+ typeof params.url === 'string' && params.url.trim()
+ ? params.url.trim()
+ : 'https://www.baidu.com'
+ const keyword =
+ typeof params.keyword === 'string' && params.keyword.trim()
+ ? params.keyword.trim()
+ : 'OpenAI'
+ const timeout =
+ Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0
+ ? Math.round(Number(params.timeoutMs))
+ : 30000
+ const waitAfterSearchMs =
+ Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0
+ ? Math.round(Number(params.waitAfterSearchMs))
+ : 1500
+
+ const runtime = await useBrowser({
+ selector,
+ startUrls: params.startUrls || [targetUrl],
+ skipDefaultStartUrls: true,
+ url: targetUrl,
+ timeoutMs: timeout,
+ reuseCurrentPage: true,
+ })
+ const page = runtime.page
+
+ const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first()
+ await searchInput.waitFor({
+ state: 'visible',
+ timeout,
+ })
+ await searchInput.fill(keyword)
+ await searchInput.press('Enter').catch(async () => {
+ const submitButton = page.locator('#su, input[type="submit"]').first()
+ await submitButton.click({ timeout })
+ })
+ await page.waitForURL(/wd=/, { timeout }).catch(() => {})
+
+ if (waitAfterSearchMs > 0) {
+ await page.waitForTimeout(waitAfterSearchMs)
+ }
+
+ if (params.captureScreenshot !== false) {
+ await page.screenshot({
+ path: artifact('baidu-search.png'),
+ fullPage: true,
+ })
+ }
+
+ const title = await page.title()
+ let apiResult = null
+ const apiUrl = typeof params.apiUrl === 'string' ? params.apiUrl.trim() : ''
+ if (apiUrl) {
+ const apiRequest = {
+ url: apiUrl,
+ method: params.apiBody === undefined ? 'GET' : 'POST',
+ timeoutMs: timeout,
+ }
+ if (params.apiBody !== undefined) {
+ apiRequest.json = params.apiBody
+ }
+ apiResult = await browserFetch(page, apiRequest)
+ }
+ log('keyword', keyword)
+ log('title', title)
+
+ return {
+ ok: true,
+ summary: \`已在百度搜索 \${keyword}\`,
+ keyword,
+ url: page.url(),
+ title,
+ apiResult,
+ }
+}`;
+}
+
+export function buildNotesTemplate(type: AutomationScriptType): string {
+ if (type === "launch-api") {
+ return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。";
+ }
+
+ return "默认示例使用 useBrowser 启动并接管页面;需要调用站内接口时传 apiUrl/apiBody,会通过 browserFetch 在浏览器上下文发起请求。";
+}
+
+export function buildDualInstanceRuntimeParamsText(
+ codes = [...DUAL_INSTANCE_DEFAULT_CODES],
+): string {
+ return `{
+ "browsers": [
+ {
+ "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}",
+ "skipDefaultStartUrls": true,
+ "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"]
+ },
+ {
+ "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}",
+ "skipDefaultStartUrls": true,
+ "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"]
+ }
+ ],
+ "timeoutMs": 45000
+}`;
+}
+
+export function buildDualInstanceRuntimeScriptText(): string {
+ return `export async function run({ baseUrl, apiKey, params, log }) {
+ const normalizeCode = (value, fallback) =>
+ String(value || fallback || "").trim().toUpperCase()
+ const normalizeStringArray = (value) =>
+ Array.isArray(value)
+ ? value
+ .map((item) => String(item || "").trim())
+ .filter(Boolean)
+ : []
+ const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => {
+ const raw = value && typeof value === "object" ? value : {}
+ const code = normalizeCode(raw.code || raw.launchCode, fallbackCode)
+ if (!code) {
+ return null
+ }
+ const startUrls = normalizeStringArray(raw.startUrls)
+ const fallbackUrls = normalizeStringArray(fallbackStartUrls)
+ const launchArgs = normalizeStringArray(raw.launchArgs)
+
+ return {
+ code,
+ skipDefaultStartUrls:
+ raw.skipDefaultStartUrls !== undefined
+ ? raw.skipDefaultStartUrls !== false
+ : defaultSkip,
+ startUrls: startUrls.length > 0 ? startUrls : fallbackUrls,
+ launchArgs,
+ }
+ }
+
+ const timeoutMs = Number.isFinite(Number(params.timeoutMs))
+ ? Math.max(1000, Math.round(Number(params.timeoutMs)))
+ : 45000
+ const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false
+
+ let browsers = Array.isArray(params.browsers)
+ ? params.browsers
+ .map((item, index) =>
+ normalizeBrowserInput(
+ item,
+ ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "",
+ ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [],
+ defaultSkipDefaultStartUrls,
+ ),
+ )
+ .filter(Boolean)
+ : []
+
+ if (browsers.length === 0) {
+ browsers = [
+ normalizeBrowserInput(
+ { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
+ ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])},
+ ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])},
+ defaultSkipDefaultStartUrls,
+ ),
+ normalizeBrowserInput(
+ { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
+ ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])},
+ ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])},
+ defaultSkipDefaultStartUrls,
+ ),
+ ].filter(Boolean)
+ }
+
+ if (browsers.length === 0) {
+ throw new Error("params.browsers 不能为空")
+ }
+
+ const headers = {
+ "Content-Type": "application/json",
+ ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}),
+ }
+
+ const post = async (path, payload) => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload),
+ })
+ const text = await response.text()
+ let body = text
+ try {
+ body = text ? JSON.parse(text) : null
+ } catch {
+ body = text
+ }
+ if (!response.ok) {
+ throw new Error(\`\${path} failed: \${response.status} \${text}\`)
+ }
+ return body
+ }
+
+ const sessions = []
+
+ for (const browser of browsers) {
+ const sessionResult = await post("/api/runtime/session", {
+ selector: { code: browser.code, matchMode: "unique" },
+ skipDefaultStartUrls: browser.skipDefaultStartUrls,
+ ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}),
+ ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}),
+ timeoutMs,
+ })
+
+ sessions.push(sessionResult)
+ }
+
+ const browserCodes = browsers.map((item) => item.code)
+ log("browserCodes", browserCodes)
+
+ return {
+ ok: true,
+ summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`,
+ browserCodes,
+ sessions,
+ }
+}`;
+}
+
+export function normalizeDualInstanceRuntimeParamsText(text: string): string {
+ const fallback = buildDualInstanceRuntimeParamsText();
+
+ try {
+ const parsed = JSON.parse(text);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return fallback;
+ }
+
+ const raw = parsed as Record;
+ const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false;
+ const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : [];
+ const browsers = rawBrowsers
+ .map((item, index) => {
+ if (!item || typeof item !== "object") {
+ return null;
+ }
+ const entry = item as Record;
+ const code = normalizeAutomationScriptTargetSelector({
+ code:
+ typeof entry.code === "string"
+ ? entry.code
+ : typeof entry.launchCode === "string"
+ ? entry.launchCode
+ : "",
+ }).code;
+ if (!code) {
+ return null;
+ }
+
+ const startUrls = Array.isArray(entry.startUrls)
+ ? entry.startUrls
+ .map((value) => String(value || "").trim())
+ .filter(Boolean)
+ : [];
+ const launchArgs = Array.isArray(entry.launchArgs)
+ ? entry.launchArgs
+ .map((value) => String(value || "").trim())
+ .filter(Boolean)
+ : [];
+
+ const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index]
+ ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
+ : [];
+
+ return {
+ code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "",
+ skipDefaultStartUrls:
+ entry.skipDefaultStartUrls !== undefined
+ ? entry.skipDefaultStartUrls !== false
+ : topLevelSkipDefaultStartUrls,
+ startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls,
+ ...(launchArgs.length > 0 ? { launchArgs } : {}),
+ };
+ })
+ .filter(
+ (
+ item,
+ ): item is {
+ code: string;
+ skipDefaultStartUrls: boolean;
+ startUrls: string[];
+ launchArgs?: string[];
+ } => item !== null,
+ );
+
+ const legacyCodes = [
+ normalizeAutomationScriptTargetSelector({
+ code: typeof raw.primaryCode === "string" ? raw.primaryCode : "",
+ }).code,
+ normalizeAutomationScriptTargetSelector({
+ code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "",
+ }).code,
+ ].filter(Boolean);
+
+ const normalizedBrowsers =
+ browsers.length > 0
+ ? browsers
+ : legacyCodes.length > 0
+ ? legacyCodes.map((code, index) => ({
+ code,
+ skipDefaultStartUrls: topLevelSkipDefaultStartUrls,
+ startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index]
+ ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
+ : [],
+ }))
+ : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({
+ code,
+ skipDefaultStartUrls: true,
+ startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index]
+ ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]]
+ : [],
+ }));
+
+ const timeoutMs =
+ Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0
+ ? Math.round(Number(raw.timeoutMs))
+ : 45000;
+
+ return JSON.stringify(
+ {
+ browsers: normalizedBrowsers,
+ timeoutMs,
+ },
+ null,
+ 2,
+ );
+ } catch {
+ return fallback;
+ }
+}
+
+
diff --git a/frontend/src/modules/browser/automationScripts/publicApi.ts b/frontend/src/modules/browser/automationScripts/publicApi.ts
index 399f5260..f79b29a1 100644
--- a/frontend/src/modules/browser/automationScripts/publicApi.ts
+++ b/frontend/src/modules/browser/automationScripts/publicApi.ts
@@ -164,48 +164,46 @@ function isPlainAutomationJSONObject(
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
-function hasSameAutomationJSONShape(
- left: Record,
- right: Record,
-): boolean {
- const leftKeys = Object.keys(left).sort();
- const rightKeys = Object.keys(right).sort();
- if (leftKeys.length !== rightKeys.length) {
- return false;
- }
+const AUTOMATION_SCRIPT_PUBLIC_API_INTERNAL_PARAM_KEYS = new Set([
+ "pageUrl",
+ "url",
+ "selectors",
+ "outputFileName",
+ "captureScreenshot",
+]);
- for (let index = 0; index < leftKeys.length; index += 1) {
- if (leftKeys[index] !== rightKeys[index]) {
- return false;
+function buildAutomationScriptPublicAPIExampleParams(
+ script: Pick,
+): Record {
+ const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {};
+ const result: Record = {};
+ Object.entries(params).forEach(([key, value]) => {
+ if (!AUTOMATION_SCRIPT_PUBLIC_API_INTERNAL_PARAM_KEYS.has(key)) {
+ result[key] = value;
}
- }
-
- return leftKeys.every((key) => {
- const leftValue = left[key];
- const rightValue = right[key];
-
- if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
- return Array.isArray(leftValue) && Array.isArray(rightValue);
- }
- if (
- isPlainAutomationJSONObject(leftValue) &&
- isPlainAutomationJSONObject(rightValue)
- ) {
- return hasSameAutomationJSONShape(leftValue, rightValue);
- }
- return true;
});
+ return result;
+}
+
+function hasAutomationScriptPublicAPIExampleParams(
+ script: Pick,
+ params: Record,
+): boolean {
+ const expectedParams = buildAutomationScriptPublicAPIExampleParams(script);
+ return Object.keys(expectedParams).every((key) => key in params);
}
function buildAutomationScriptPublicAPIDefaultRequestExample(
- script: Pick,
+ script: Pick,
config: AutomationScriptPublicAPIConfig,
): string {
- const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {};
+ const params = buildAutomationScriptPublicAPIExampleParams(script);
return JSON.stringify(
{
- code: "",
+ instance: {
+ type: "script-default",
+ },
params,
timeoutMs: config.timeoutMs,
},
@@ -231,7 +229,7 @@ function buildAutomationScriptPublicAPIDefaultResponseExample(): string {
function isLegacyAutomationScriptPublicAPIRequestExample(
- script: Pick,
+ script: Pick,
parsedBody: Record,
): boolean {
const allowedLegacyKeys = new Set(["code", "launchCode", "param", "params", "timeoutMs"]);
@@ -249,11 +247,9 @@ function isLegacyAutomationScriptPublicAPIRequestExample(
return false;
}
- const expectedParams = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText);
if (
- expectedParams &&
paramsValue !== undefined &&
- !hasSameAutomationJSONShape(paramsValue, expectedParams)
+ !hasAutomationScriptPublicAPIExampleParams(script, paramsValue)
) {
return false;
}
@@ -262,7 +258,7 @@ function isLegacyAutomationScriptPublicAPIRequestExample(
}
function shouldUseDerivedAutomationScriptPublicAPIRequestBody(
- script: Pick,
+ script: Pick,
config: AutomationScriptPublicAPIConfig,
): boolean {
const sourceText = config.requestBodyText.trim();
@@ -289,12 +285,12 @@ function shouldUseDerivedAutomationScriptPublicAPIRequestBody(
return true;
}
- const allowedKeys = new Set(["code", "params", "timeoutMs"]);
+ const allowedKeys = new Set(["code", "instance", "params", "timeoutMs"]);
if (Object.keys(parsedBody).some((key) => !allowedKeys.has(key))) {
return false;
}
- if (!("code" in parsedBody)) {
+ if (!("code" in parsedBody) && !("instance" in parsedBody)) {
return false;
}
@@ -303,18 +299,24 @@ function shouldUseDerivedAutomationScriptPublicAPIRequestBody(
return false;
}
+ const instanceValue = parsedBody.instance;
+ if (instanceValue !== undefined) {
+ if (!isPlainAutomationJSONObject(instanceValue)) {
+ return false;
+ }
+ if (String(instanceValue.type || "").trim() !== "script-default") {
+ return false;
+ }
+ }
+
const paramsValue = parsedBody.params;
if (paramsValue !== undefined && !isPlainAutomationJSONObject(paramsValue)) {
return false;
}
- const expectedParams = safeParseAutomationScriptPublicAPIJSONObject(
- script.paramsText,
- );
if (
- expectedParams &&
paramsValue !== undefined &&
- !hasSameAutomationJSONShape(paramsValue, expectedParams)
+ !hasAutomationScriptPublicAPIExampleParams(script, paramsValue)
) {
return false;
}
@@ -344,7 +346,7 @@ function shouldUseDerivedAutomationScriptPublicAPIResponseBody(
}
export function buildAutomationScriptPublicAPIRequestExample(
- script: Pick,
+ script: Pick,
config: AutomationScriptPublicAPIConfig,
): string {
if (!shouldUseDerivedAutomationScriptPublicAPIRequestBody(script, config)) {
diff --git a/frontend/src/modules/browser/automationScripts/publicApiInstances.ts b/frontend/src/modules/browser/automationScripts/publicApiInstances.ts
new file mode 100644
index 00000000..ba8f277e
--- /dev/null
+++ b/frontend/src/modules/browser/automationScripts/publicApiInstances.ts
@@ -0,0 +1,120 @@
+import { safeParseAutomationScriptPublicAPIJSONObject } from "./publicApiUtils";
+
+function normalizeLaunchCode(value: unknown): string {
+ return String(value || "").trim().toUpperCase();
+}
+
+function isPlainJSONObject(value: unknown): value is Record {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+export function readAutomationScriptPublicAPIParamObject(
+ body: Record,
+): Record {
+ if (isPlainJSONObject(body.param)) {
+ return body.param;
+ }
+ if (isPlainJSONObject(body.params)) {
+ return body.params;
+ }
+ return {};
+}
+
+export function readAutomationScriptPublicAPITargetCode(bodyText: string): string {
+ const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText);
+ if (!body) return "";
+ const instance = isPlainJSONObject(body.instance) ? body.instance : null;
+ const selector = isPlainJSONObject(instance?.selector) ? instance.selector : null;
+ if (selector?.code) {
+ return normalizeLaunchCode(selector.code);
+ }
+ return normalizeLaunchCode(body.code || body.launchCode);
+}
+
+export function readAutomationScriptPublicAPIInstanceType(bodyText: string): string {
+ const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText);
+ if (!body) return "";
+ const instance = isPlainJSONObject(body.instance) ? body.instance : null;
+ return String(instance?.type || "").trim();
+}
+
+export function normalizeAutomationScriptPublicAPIRequestBodyForInvoke(
+ bodyText: string,
+): string {
+ const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText);
+ if (!body) {
+ return bodyText;
+ }
+
+ const params = readAutomationScriptPublicAPIParamObject(body);
+ const topLevelParams: Record = {};
+ for (const [key, value] of Object.entries(body)) {
+ if (
+ [
+ "code",
+ "launchCode",
+ "instance",
+ "params",
+ "param",
+ "timeoutMs",
+ "selector",
+ ].includes(key)
+ ) {
+ continue;
+ }
+ topLevelParams[key] = value;
+ }
+
+ const nextBody: Record = {
+ params: {
+ ...params,
+ ...topLevelParams,
+ },
+ };
+
+ if (isPlainJSONObject(body.instance)) {
+ nextBody.instance = body.instance;
+ } else {
+ const code = normalizeLaunchCode(body.code || body.launchCode);
+ if (code) {
+ nextBody.instance = {
+ type: "existing",
+ selector: { code },
+ };
+ }
+ }
+
+ if (Number.isFinite(Number(body.timeoutMs))) {
+ nextBody.timeoutMs = Math.round(Number(body.timeoutMs));
+ }
+
+ return JSON.stringify(nextBody, null, 2);
+}
+
+export function buildAutomationScriptPublicAPIRequestBodyWithTargetCode(
+ currentBodyText: string,
+ fallbackBodyText: string,
+ code: string,
+): string {
+ const sourceBody =
+ safeParseAutomationScriptPublicAPIJSONObject(currentBodyText) ||
+ safeParseAutomationScriptPublicAPIJSONObject(fallbackBodyText) ||
+ {};
+ const sourceParam = readAutomationScriptPublicAPIParamObject(sourceBody);
+ const nextBody: Record = {
+ ...sourceBody,
+ instance: {
+ type: "existing",
+ selector: {
+ code: normalizeLaunchCode(code),
+ },
+ },
+ params: sourceParam,
+ };
+ delete nextBody.code;
+ delete nextBody.launchCode;
+ delete nextBody.selector;
+ delete nextBody.param;
+
+ return JSON.stringify(nextBody, null, 2);
+}
diff --git a/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx b/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx
new file mode 100644
index 00000000..c3133381
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx
@@ -0,0 +1,129 @@
+import { Copy } from "lucide-react";
+import { Button, Textarea } from "../../../shared/components";
+import { copyText } from "./AutomationScriptPublicApiModal.helpers";
+import type { AutomationScriptPublicAPIConfig } from "../automationScripts";
+
+interface AutomationScriptPublicApiBodyExamplesProps {
+ busy: boolean;
+ resolvedConfig: AutomationScriptPublicAPIConfig;
+ requestExampleFallback: string;
+ responseExampleFallback: string;
+ requestBodyError: string;
+ responseBodyError: string;
+ updateConfig: (patch: Partial) => void;
+}
+
+export function AutomationScriptPublicApiBodyExamples({
+ busy,
+ resolvedConfig,
+ requestExampleFallback,
+ responseExampleFallback,
+ requestBodyError,
+ responseBodyError,
+ updateConfig,
+}: AutomationScriptPublicApiBodyExamplesProps) {
+ return (
+
+
+
+
+ 请求 Body
+
+
+
+
+
+
+
+
+
+
+
+ 响应示例
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.helpers.ts b/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.helpers.ts
new file mode 100644
index 00000000..eeb511dc
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.helpers.ts
@@ -0,0 +1,231 @@
+import { toast } from "../../../shared/components";
+import type { AutomationScriptPublicApiInvokeResult } from "../automationScriptApi";
+import {
+ applyAutomationScriptPublicAPIVariables,
+ buildAutomationScriptPublicAPIPath,
+ buildAutomationScriptPublicAPIRequestExample,
+ collectAutomationScriptPublicAPIVariableValues,
+ readAutomationScriptPublicAPIParamObject,
+ type AutomationScriptPublicAPIConfig,
+ type AutomationScriptRecord,
+} from "../automationScripts";
+const INSTANCE_VARIABLE_NAMES = new Set([
+ "code",
+ "launchCode",
+ "primaryCode",
+ "secondaryCode",
+]);
+
+export function isInstanceVariableName(name: string): boolean {
+ return INSTANCE_VARIABLE_NAMES.has(name.trim());
+}
+
+export function parseJSONText(
+ text: string,
+): { ok: boolean; value: unknown | null; error: string } {
+ const sourceText = String(text || "").trim();
+ if (!sourceText) {
+ return { ok: true, value: null, error: "" };
+ }
+
+ try {
+ return {
+ ok: true,
+ value: JSON.parse(sourceText),
+ error: "",
+ };
+ } catch (error: unknown) {
+ return {
+ ok: false,
+ value: null,
+ error: error instanceof Error ? error.message : "JSON 解析失败",
+ };
+ }
+}
+
+export function safeParseJSONObject(text: string): Record | null {
+ const parsed = parseJSONText(text);
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
+ return null;
+ }
+ if (Array.isArray(parsed.value)) {
+ return null;
+ }
+ return parsed.value as Record;
+}
+
+function normalizeLaunchCode(value: unknown): string {
+ return String(value || "").trim().toUpperCase();
+}
+
+export function readPublicApiDualTargetCode(bodyText: string, index: number): string {
+ const body = safeParseJSONObject(bodyText);
+ if (!body) return "";
+ const param = readAutomationScriptPublicAPIParamObject(body);
+ const browsers = Array.isArray(param.browsers)
+ ? param.browsers
+ : Array.isArray(body.browsers)
+ ? body.browsers
+ : [];
+ const browser = browsers[index];
+ if (!browser || typeof browser !== "object" || Array.isArray(browser)) {
+ return "";
+ }
+ return normalizeLaunchCode(
+ (browser as Record).code ||
+ (browser as Record).launchCode,
+ );
+}
+
+export function buildRequestBodyWithDualTargetCode(
+ currentBodyText: string,
+ fallbackBodyText: string,
+ index: number,
+ code: string,
+): string {
+ const sourceBody =
+ safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
+ const sourceParam = readAutomationScriptPublicAPIParamObject(sourceBody);
+ const sourceBrowsers = Array.isArray(sourceParam.browsers)
+ ? sourceParam.browsers
+ : [];
+ const nextBrowsers = [...sourceBrowsers];
+ const currentBrowser = nextBrowsers[index];
+ const nextBrowser =
+ currentBrowser && typeof currentBrowser === "object" && !Array.isArray(currentBrowser)
+ ? { ...(currentBrowser as Record) }
+ : {};
+ nextBrowser.code = normalizeLaunchCode(code);
+ delete nextBrowser.launchCode;
+ nextBrowsers[index] = nextBrowser;
+
+ const nextBody: Record = {
+ ...sourceBody,
+ params: {
+ ...sourceParam,
+ browsers: nextBrowsers,
+ },
+ };
+ delete nextBody.param;
+ delete nextBody.browsers;
+
+ return JSON.stringify(nextBody, null, 2);
+}
+
+export function buildCurlPreview(
+ script: AutomationScriptRecord,
+ config: AutomationScriptPublicAPIConfig,
+ launchBaseUrl: string,
+ apiAuthEnabled: boolean,
+ apiAuthHeader: string,
+): string {
+ const lines = [
+ `curl -X ${config.method} ${launchBaseUrl}${buildAutomationScriptPublicAPIPath(config.path)} \\`,
+ ` -H "Content-Type: application/json" \\`,
+ ];
+
+ if (apiAuthEnabled && apiAuthHeader.trim()) {
+ lines.push(` -H "${apiAuthHeader}: " \\`);
+ }
+
+ const requestBody = applyAutomationScriptPublicAPIVariables(
+ buildAutomationScriptPublicAPIRequestExample(script, config),
+ config.variables,
+ collectAutomationScriptPublicAPIVariableValues(config),
+ ).bodyText
+ .split("\n")
+ .map((line, index, all) =>
+ index === all.length - 1 ? ` -d '${line}'` : ` -d '${line}`,
+ )
+ .join("\n");
+
+ lines.push(requestBody);
+ return lines.join("\n");
+}
+
+export function formatInvokeResult(result: AutomationScriptPublicApiInvokeResult): string {
+ if (result.bodyJson !== null) {
+ try {
+ return JSON.stringify(result.bodyJson, null, 2);
+ } catch {
+ // noop
+ }
+ }
+ return result.bodyText.trim() || "(empty)";
+}
+
+export interface PublicApiOutputEntry {
+ key: string;
+ label: string;
+ path: string;
+}
+
+export function parsePublicApiOutputEntries(result: AutomationScriptPublicApiInvokeResult | null): PublicApiOutputEntry[] {
+ if (!result?.bodyJson || typeof result.bodyJson !== "object" || Array.isArray(result.bodyJson)) {
+ return [];
+ }
+
+ const seen = new Set();
+ const outputs: PublicApiOutputEntry[] = [];
+ const addOutput = (key: string, value: string) => {
+ const path = value.trim();
+ if (!path || seen.has(path)) {
+ return;
+ }
+ seen.add(path);
+ outputs.push({ key, label: formatPublicApiOutputLabel(key), path });
+ };
+ const collect = (value: unknown, keyHint = "") => {
+ if (!value) {
+ return;
+ }
+ if (typeof value === "string") {
+ if (/path$/i.test(keyHint) || keyHint === "downloadAddress") {
+ addOutput(keyHint, value);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((item) => collect(item, keyHint));
+ return;
+ }
+ if (typeof value !== "object") {
+ return;
+ }
+ Object.entries(value as Record).forEach(([key, nestedValue]) =>
+ collect(nestedValue, key),
+ );
+ };
+
+ collect(result.bodyJson);
+ return outputs;
+}
+
+function formatPublicApiOutputLabel(key: string): string {
+ switch (key) {
+ case "outputPath":
+ case "downloadPath":
+ case "downloadAddress":
+ return "导出文件";
+ case "screenshotPath":
+ return "截图文件";
+ case "artifacts":
+ return "导出文件";
+ default:
+ return key;
+ }
+}
+
+export function formatPublicApiOutputName(path: string): string {
+ const segments = path.split(/[\\/]/).filter(Boolean);
+ return segments[segments.length - 1] || path;
+}
+
+export async function copyText(text: string, successMessage: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(successMessage);
+ } catch {
+ toast.error("复制失败");
+ }
+}
diff --git a/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.tsx b/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.tsx
index f5086230..99014ad6 100644
--- a/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.tsx
+++ b/frontend/src/modules/browser/components/AutomationScriptPublicApiModal.tsx
@@ -1,39 +1,40 @@
-import { useEffect, useRef, useState } from "react";
-import { Copy, Play, Plus, Sparkles, Trash2 } from "lucide-react";
-import {
- Button,
- FormItem,
- Input,
- Modal,
- Select,
- Switch,
- Textarea,
- toast,
-} from "../../../shared/components";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "../../../shared/components";
import {
invokeAutomationScriptPublicApi,
type AutomationScriptPublicApiInvokeResult,
} from "../automationScriptApi";
-import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
+import { openCorePath } from "../api";
import type { BrowserProfile } from "../types";
import {
- AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS,
applyAutomationScriptPublicAPIVariables,
buildAutomationScriptPublicAPIPath,
buildAutomationScriptPublicAPIRequestExample,
+ buildAutomationScriptPublicAPIRequestBodyWithTargetCode,
buildAutomationScriptPublicAPIResponseExample,
collectAutomationScriptPublicAPIVariableValues,
DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
isAutomationScriptPublicAPIVariableName,
+ normalizeAutomationScriptPublicAPIRequestBodyForInvoke,
normalizeAutomationScriptPublicAPIConfig,
prepareAutomationScriptPublicAPIConfigForSave,
+ readAutomationScriptPublicAPIInstanceType,
+ readAutomationScriptPublicAPITargetCode,
resolveAutomationScriptPublicAPIConfig,
suggestAutomationScriptPublicAPIPath,
type AutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIVariable,
type AutomationScriptRecord,
} from "../automationScripts";
-
+import {
+ buildRequestBodyWithDualTargetCode,
+ isInstanceVariableName,
+ parseJSONText,
+ parsePublicApiOutputEntries,
+ readPublicApiDualTargetCode,
+ safeParseJSONObject,
+} from "./AutomationScriptPublicApiModal.helpers";
+import { AutomationScriptPublicApiModalView } from "./AutomationScriptPublicApiModalView";
interface AutomationScriptPublicApiModalProps {
open: boolean;
script: AutomationScriptRecord;
@@ -50,192 +51,6 @@ interface AutomationScriptPublicApiModalProps {
) => Promise | boolean;
}
-function parseJSONText(
- text: string,
-): { ok: boolean; value: unknown | null; error: string } {
- const sourceText = String(text || "").trim();
- if (!sourceText) {
- return { ok: true, value: null, error: "" };
- }
-
- try {
- return {
- ok: true,
- value: JSON.parse(sourceText),
- error: "",
- };
- } catch (error: unknown) {
- return {
- ok: false,
- value: null,
- error: error instanceof Error ? error.message : "JSON 解析失败",
- };
- }
-}
-
-function safeParseJSONObject(text: string): Record | null {
- const parsed = parseJSONText(text);
- if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
- return null;
- }
- if (Array.isArray(parsed.value)) {
- return null;
- }
- return parsed.value as Record;
-}
-
-function normalizeLaunchCode(value: unknown): string {
- return String(value || "").trim().toUpperCase();
-}
-
-function readPublicApiTargetCode(bodyText: string): string {
- const body = safeParseJSONObject(bodyText);
- if (!body) return "";
- return normalizeLaunchCode(body.code || body.launchCode);
-}
-
-function readPublicApiParamObject(
- body: Record,
-): Record {
- if (body.param && typeof body.param === "object" && !Array.isArray(body.param)) {
- return body.param as Record;
- }
- if (body.params && typeof body.params === "object" && !Array.isArray(body.params)) {
- return body.params as Record;
- }
- return {};
-}
-
-function readPublicApiDualTargetCode(bodyText: string, index: number): string {
- const body = safeParseJSONObject(bodyText);
- if (!body) return "";
- const param = readPublicApiParamObject(body);
- const browsers = Array.isArray(param.browsers)
- ? param.browsers
- : Array.isArray(body.browsers)
- ? body.browsers
- : [];
- const browser = browsers[index];
- if (!browser || typeof browser !== "object" || Array.isArray(browser)) {
- return "";
- }
- return normalizeLaunchCode(
- (browser as Record).code ||
- (browser as Record).launchCode,
- );
-}
-
-function buildRequestBodyWithTargetCode(
- currentBodyText: string,
- fallbackBodyText: string,
- code: string,
-): string {
- const sourceBody =
- safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
- const sourceParam =
- sourceBody.param && typeof sourceBody.param === "object" && !Array.isArray(sourceBody.param)
- ? sourceBody.param
- : sourceBody.params && typeof sourceBody.params === "object" && !Array.isArray(sourceBody.params)
- ? sourceBody.params
- : {};
- const nextBody: Record = {
- ...sourceBody,
- code: normalizeLaunchCode(code),
- param: sourceParam,
- };
- delete nextBody.launchCode;
- delete nextBody.selector;
- delete nextBody.params;
-
- return JSON.stringify(nextBody, null, 2);
-}
-
-function buildRequestBodyWithDualTargetCode(
- currentBodyText: string,
- fallbackBodyText: string,
- index: number,
- code: string,
-): string {
- const sourceBody =
- safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
- const sourceParam = readPublicApiParamObject(sourceBody);
- const sourceBrowsers = Array.isArray(sourceParam.browsers)
- ? sourceParam.browsers
- : [];
- const nextBrowsers = [...sourceBrowsers];
- const currentBrowser = nextBrowsers[index];
- const nextBrowser =
- currentBrowser && typeof currentBrowser === "object" && !Array.isArray(currentBrowser)
- ? { ...(currentBrowser as Record) }
- : {};
- nextBrowser.code = normalizeLaunchCode(code);
- delete nextBrowser.launchCode;
- nextBrowsers[index] = nextBrowser;
-
- const nextBody: Record = {
- ...sourceBody,
- param: {
- ...sourceParam,
- browsers: nextBrowsers,
- },
- };
- delete nextBody.params;
- delete nextBody.browsers;
-
- return JSON.stringify(nextBody, null, 2);
-}
-
-function buildCurlPreview(
- script: AutomationScriptRecord,
- config: AutomationScriptPublicAPIConfig,
- launchBaseUrl: string,
- apiAuthEnabled: boolean,
- apiAuthHeader: string,
-): string {
- const lines = [
- `curl -X ${config.method} ${launchBaseUrl}${buildAutomationScriptPublicAPIPath(config.path)} \\`,
- ` -H "Content-Type: application/json" \\`,
- ];
-
- if (apiAuthEnabled && apiAuthHeader.trim()) {
- lines.push(` -H "${apiAuthHeader}: " \\`);
- }
-
- const requestBody = applyAutomationScriptPublicAPIVariables(
- buildAutomationScriptPublicAPIRequestExample(script, config),
- config.variables,
- collectAutomationScriptPublicAPIVariableValues(config),
- ).bodyText
- .split("\n")
- .map((line, index, all) =>
- index === all.length - 1 ? ` -d '${line}'` : ` -d '${line}`,
- )
- .join("\n");
-
- lines.push(requestBody);
- return lines.join("\n");
-}
-
-function formatInvokeResult(result: AutomationScriptPublicApiInvokeResult): string {
- if (result.bodyJson !== null) {
- try {
- return JSON.stringify(result.bodyJson, null, 2);
- } catch {
- // noop
- }
- }
- return result.bodyText.trim() || "(empty)";
-}
-
-async function copyText(text: string, successMessage: string) {
- try {
- await navigator.clipboard.writeText(text);
- toast.success(successMessage);
- } catch {
- toast.error("复制失败");
- }
-}
-
export function AutomationScriptPublicApiModal({
open,
script,
@@ -260,6 +75,9 @@ export function AutomationScriptPublicApiModal({
requestBodyText: "",
},
);
+ const requestBodySource = resolvedConfig.requestBodyText.trim()
+ ? resolvedConfig.requestBodyText
+ : requestExampleFallback;
const responseExampleFallback = buildAutomationScriptPublicAPIResponseExample(
script,
{
@@ -273,13 +91,23 @@ export function AutomationScriptPublicApiModal({
collectAutomationScriptPublicAPIVariableValues(resolvedConfig),
);
const resolvedRequestBodyText = resolvedRequestBody.bodyText;
- const invalidVariableNames = resolvedConfig.variables
+ const visibleVariables = resolvedConfig.variables
+ .map((variable, index) => ({ variable, index }))
+ .filter(({ variable }) => !isInstanceVariableName(variable.name));
+ const visibleVariableNames = new Set(
+ visibleVariables.map(({ variable }) => variable.name),
+ );
+ const invalidVariableNames = visibleVariables
+ .map(({ variable }) => variable)
.filter((variable) => !isAutomationScriptPublicAPIVariableName(variable.name))
.map((variable) => variable.name);
+ const missingVisibleVariables = resolvedRequestBody.missingRequired.filter(
+ (name) => visibleVariableNames.has(name),
+ );
const variableError = invalidVariableNames.length
? `变量名只能使用字母、数字、下划线,且不能以数字开头:${invalidVariableNames.join(", ")}`
- : resolvedRequestBody.missingRequired.length
- ? `必填变量缺少默认值:${resolvedRequestBody.missingRequired.join(", ")}`
+ : missingVisibleVariables.length
+ ? `必填变量缺少默认值:${missingVisibleVariables.join(", ")}`
: "";
const responseBodyValidation = parseJSONText(resolvedConfig.responseBodyText);
const requestBodyError =
@@ -291,7 +119,12 @@ export function AutomationScriptPublicApiModal({
? `响应示例不是合法 JSON:${responseBodyValidation.error}`
: "";
const isDualInstanceRuntimeScript = script.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID;
- const selectedTargetCode = readPublicApiTargetCode(resolvedRequestBodyText);
+ const selectedTargetCode = readAutomationScriptPublicAPITargetCode(
+ resolvedRequestBodyText,
+ );
+ const selectedInstanceType = readAutomationScriptPublicAPIInstanceType(
+ resolvedRequestBodyText,
+ );
const selectedPrimaryTargetCode = readPublicApiDualTargetCode(
resolvedRequestBodyText,
0,
@@ -307,14 +140,24 @@ export function AutomationScriptPublicApiModal({
: "两个实例 Code 必填"
: selectedTargetCode
? ""
- : "实例 Code 必填";
+ : selectedInstanceType === "script-default"
+ ? ""
+ : "实例 Code 必填";
+ const invokeDisabled =
+ busy ||
+ !resolvedConfig.enabled ||
+ !!variableError ||
+ !!requestBodyError ||
+ !!responseBodyError ||
+ !!targetCodeError;
const [apiKey, setApiKey] = useState("");
const [invoking, setInvoking] = useState(false);
const [invokeResult, setInvokeResult] =
useState(null);
const [invokeError, setInvokeError] = useState("");
- const testSectionRef = useRef(null);
+ const testSectionRef = useRef(null);
+ const outputEntries = parsePublicApiOutputEntries(invokeResult);
useEffect(() => {
if (!open) {
@@ -355,8 +198,8 @@ export function AutomationScriptPublicApiModal({
const handleTargetCodeChange = (code: string) => {
updateConfig({
- requestBodyText: buildRequestBodyWithTargetCode(
- resolvedConfig.requestBodyText,
+ requestBodyText: buildAutomationScriptPublicAPIRequestBodyWithTargetCode(
+ requestBodySource,
requestExampleFallback,
code,
),
@@ -366,7 +209,7 @@ export function AutomationScriptPublicApiModal({
const handleDualTargetCodeChange = (index: number, code: string) => {
updateConfig({
requestBodyText: buildRequestBodyWithDualTargetCode(
- resolvedConfig.requestBodyText,
+ requestBodySource,
requestExampleFallback,
index,
code,
@@ -399,7 +242,7 @@ export function AutomationScriptPublicApiModal({
variables: [
...resolvedConfig.variables,
{
- name: baseName || `variable${resolvedConfig.variables.length + 1}`,
+ name: baseName || `variable${visibleVariables.length + 1}`,
defaultValue: "",
description: "",
required: false,
@@ -453,7 +296,9 @@ export function AutomationScriptPublicApiModal({
const result = await invokeAutomationScriptPublicApi({
url: fullURL,
method: resolvedConfig.method,
- bodyText: resolvedRequestBodyText,
+ bodyText: normalizeAutomationScriptPublicAPIRequestBodyForInvoke(
+ resolvedRequestBodyText,
+ ),
apiKey,
authHeader: apiAuthHeader,
timeoutMs: resolvedConfig.timeoutMs + 10000,
@@ -473,403 +318,56 @@ export function AutomationScriptPublicApiModal({
}
};
+ const handleOpenOutputPath = async (path: string) => {
+ try {
+ await openCorePath(path);
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : "打开目录失败";
+ toast.error(message);
+ }
+ };
+
return (
-
- 完成
-
- }
- >
-
-
-
-
- 测试接口
-
-
-
-
-
-
-
-
- 目标地址
-
-
- {fullURL}
-
-
-
- {apiAuthEnabled ? (
-
- setApiKey(event.target.value)}
- placeholder="留空则使用当前应用里的 Launch API Key"
- />
-
- ) : (
-
- 当前 Launch API 未启用认证,可以直接测试。
-
- )}
-
-
-
-
-
- 返回结果
-
- {invokeResult ? (
-
- HTTP {invokeResult.status} {invokeResult.statusText}
-
- ) : null}
-
-
- {invokeError ? (
-
- {invokeError}
-
- ) : null}
-
- {!invokeError && !invokeResult ? (
-
- 发送一次测试请求后,这里显示真实响应。
-
- ) : null}
-
- {invokeResult ? (
-
- {formatInvokeResult(invokeResult)}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {resolvedConfig.variables.length > 0 ? (
-
- ) : (
-
- 未配置变量
-
- )}
-
- {variableError ? (
-
- {variableError}
-
- ) : (
-
- Body 中使用 {"${name}"},测试和 curl 会替换为默认值。
-
- )}
-
-
- {isDualInstanceRuntimeScript ? (
-
-
handleDualTargetCodeChange(0, code)}
- />
- handleDualTargetCodeChange(1, code)}
- />
-
- ) : (
-
- )}
-
-
-
-
-
- Body 入参
-
-
-
-
-
-
-
-
-
-
- Response 出参
-
-
-
-
-
-
-
-
-
-
+ busy={busy}
+ script={script}
+ launchBaseUrl={launchBaseUrl}
+ apiAuthEnabled={apiAuthEnabled}
+ apiAuthHeader={apiAuthHeader}
+ profiles={profiles}
+ fullURL={fullURL}
+ fullPath={fullPath}
+ resolvedConfig={resolvedConfig}
+ requestExampleFallback={requestExampleFallback}
+ responseExampleFallback={responseExampleFallback}
+ visibleVariables={visibleVariables}
+ variableError={variableError}
+ requestBodyError={requestBodyError}
+ responseBodyError={responseBodyError}
+ isDualInstanceRuntimeScript={isDualInstanceRuntimeScript}
+ selectedTargetCode={selectedTargetCode}
+ selectedPrimaryTargetCode={selectedPrimaryTargetCode}
+ selectedSecondaryTargetCode={selectedSecondaryTargetCode}
+ invokeDisabled={invokeDisabled}
+ apiKey={apiKey}
+ setApiKey={setApiKey}
+ invoking={invoking}
+ invokeResult={invokeResult}
+ invokeError={invokeError}
+ outputEntries={outputEntries}
+ testSectionRef={testSectionRef}
+ updateConfig={updateConfig}
+ updateVariable={updateVariable}
+ handleApplySuggestedPath={handleApplySuggestedPath}
+ handleAddVariable={handleAddVariable}
+ handleRemoveVariable={handleRemoveVariable}
+ handleTargetCodeChange={handleTargetCodeChange}
+ handleDualTargetCodeChange={handleDualTargetCodeChange}
+ handleInvoke={handleInvoke}
+ handleOpenOutputPath={handleOpenOutputPath}
+ />
);
}
+
diff --git a/frontend/src/modules/browser/components/AutomationScriptPublicApiModalView.tsx b/frontend/src/modules/browser/components/AutomationScriptPublicApiModalView.tsx
new file mode 100644
index 00000000..6ac36625
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptPublicApiModalView.tsx
@@ -0,0 +1,474 @@
+import type { RefObject } from "react";
+import { Copy, FolderOpen, Play, Plus, Sparkles, Trash2 } from "lucide-react";
+import { Button, FormItem, Input, Modal, Select, Switch } from "../../../shared/components";
+import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
+import {
+ buildCurlPreview,
+ copyText,
+ formatInvokeResult,
+ formatPublicApiOutputName,
+ type PublicApiOutputEntry,
+} from "./AutomationScriptPublicApiModal.helpers";
+import {
+ AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS,
+ type AutomationScriptPublicAPIConfig,
+ type AutomationScriptPublicAPIVariable,
+ type AutomationScriptRecord,
+} from "../automationScripts";
+import type { BrowserProfile } from "../types";
+import type { AutomationScriptPublicApiInvokeResult } from "../automationScriptApi";
+import { AutomationScriptPublicApiBodyExamples } from "./AutomationScriptPublicApiBodyExamples";
+
+interface VisiblePublicApiVariable {
+ variable: AutomationScriptPublicAPIVariable;
+ index: number;
+}
+
+interface AutomationScriptPublicApiModalViewProps {
+ open: boolean;
+ onClose: () => void;
+ busy: boolean;
+ script: AutomationScriptRecord;
+ launchBaseUrl: string;
+ apiAuthEnabled: boolean;
+ apiAuthHeader: string;
+ profiles: BrowserProfile[];
+ fullURL: string;
+ fullPath: string;
+ resolvedConfig: AutomationScriptPublicAPIConfig;
+ requestExampleFallback: string;
+ responseExampleFallback: string;
+ visibleVariables: VisiblePublicApiVariable[];
+ variableError: string;
+ requestBodyError: string;
+ responseBodyError: string;
+ isDualInstanceRuntimeScript: boolean;
+ selectedTargetCode: string;
+ selectedPrimaryTargetCode: string;
+ selectedSecondaryTargetCode: string;
+ invokeDisabled: boolean;
+ apiKey: string;
+ setApiKey: (value: string) => void;
+ invoking: boolean;
+ invokeResult: AutomationScriptPublicApiInvokeResult | null;
+ invokeError: string;
+ outputEntries: PublicApiOutputEntry[];
+ testSectionRef: RefObject;
+ updateConfig: (patch: Partial) => void;
+ updateVariable: (index: number, patch: Partial) => void;
+ handleApplySuggestedPath: () => void;
+ handleAddVariable: () => void;
+ handleRemoveVariable: (index: number) => void;
+ handleTargetCodeChange: (code: string) => void;
+ handleDualTargetCodeChange: (index: number, code: string) => void;
+ handleInvoke: () => Promise;
+ handleOpenOutputPath: (path: string) => Promise;
+}
+
+export function AutomationScriptPublicApiModalView({
+ open,
+ onClose,
+ busy,
+ script,
+ launchBaseUrl,
+ apiAuthEnabled,
+ apiAuthHeader,
+ profiles,
+ fullURL,
+ fullPath,
+ resolvedConfig,
+ requestExampleFallback,
+ responseExampleFallback,
+ visibleVariables,
+ variableError,
+ requestBodyError,
+ responseBodyError,
+ isDualInstanceRuntimeScript,
+ selectedTargetCode,
+ selectedPrimaryTargetCode,
+ selectedSecondaryTargetCode,
+ invokeDisabled,
+ apiKey,
+ setApiKey,
+ invoking,
+ invokeResult,
+ invokeError,
+ outputEntries,
+ testSectionRef,
+ updateConfig,
+ updateVariable,
+ handleApplySuggestedPath,
+ handleAddVariable,
+ handleRemoveVariable,
+ handleTargetCodeChange,
+ handleDualTargetCodeChange,
+ handleInvoke,
+ handleOpenOutputPath,
+}: AutomationScriptPublicApiModalViewProps) {
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+ 目标地址
+
+
+ {fullURL}
+
+
+
+ {apiAuthEnabled ? (
+
+ setApiKey(event.target.value)}
+ placeholder="留空则使用当前应用里的 Launch API Key"
+ />
+
+ ) : (
+
+ 当前 Launch API 未启用认证,可以直接测试。
+
+ )}
+
+
+
+
+
+ 返回结果
+
+ {invokeResult ? (
+
+ HTTP {invokeResult.status} {invokeResult.statusText}
+
+ ) : null}
+
+
+ {invokeError ? (
+
+ {invokeError}
+
+ ) : null}
+
+ {!invokeError && !invokeResult ? (
+
+ 发送一次测试请求后,这里显示真实响应。
+
+ ) : null}
+
+ {invokeResult ? (
+
+
+ {formatInvokeResult(invokeResult)}
+
+ {outputEntries.length > 0 ? (
+
+
+ {outputEntries.map((output) => (
+
+
+
+ {output.label} · {formatPublicApiOutputName(output.path)}
+
+
+ {output.path}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {visibleVariables.length > 0 ? (
+
+ ) : (
+
+ 未配置变量
+
+ )}
+
+ {variableError ? (
+
+ {variableError}
+
+ ) : (
+
+ 用 {"{{name}}"} 占位;实例 Code 由下方实例选择维护。
+
+ )}
+
+
+ {isDualInstanceRuntimeScript ? (
+
+
handleDualTargetCodeChange(0, code)}
+ />
+ handleDualTargetCodeChange(1, code)}
+ />
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.helpers.ts b/frontend/src/modules/browser/components/AutomationScriptRunModal.helpers.ts
new file mode 100644
index 00000000..3798ef43
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.helpers.ts
@@ -0,0 +1,470 @@
+import { toast } from "../../../shared/components";
+import {
+ applyAutomationScriptPublicAPIVariables,
+ collectAutomationScriptPublicAPIVariableValues,
+ type AutomationScriptPublicAPIConfig,
+ type AutomationScriptRecord,
+} from "../automationScripts";
+import type { AutomationDemoSession } from "../demoSession";
+import type { BrowserProfile } from "../types";
+import type { ResultOutputEntry, RunVariableInputs, SelectableProfile } from "./AutomationScriptRunModal.types";
+
+export function validateJsonObjectText(
+ text: string,
+ label: string,
+ required: boolean,
+): string {
+ const normalized = text.trim();
+ if (!normalized) {
+ return required ? `${label}不能为空` : "";
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return `${label}必须是 JSON 对象`;
+ }
+ return "";
+ } catch {
+ return `${label}不是合法 JSON`;
+ }
+}
+
+export function formatDateTime(value?: string): string {
+ if (!value) {
+ return "-";
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString("zh-CN", { hour12: false });
+}
+
+export function formatDuration(durationMs?: number): string {
+ if (!durationMs || durationMs <= 0) {
+ return "-";
+ }
+ if (durationMs < 1000) {
+ return `${durationMs} ms`;
+ }
+ return `${(durationMs / 1000).toFixed(2)} s`;
+}
+
+export function parseRunResultOutputs(resultText?: string): ResultOutputEntry[] {
+ const normalized = String(resultText || "").trim();
+ if (!normalized) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return [];
+ }
+
+ const seen = new Set();
+ const outputs: ResultOutputEntry[] = [];
+
+ const addOutput = (key: string, value: string) => {
+ const path = value.trim();
+ if (!path || seen.has(path)) {
+ return;
+ }
+ seen.add(path);
+ outputs.push({
+ key,
+ label: formatRunResultOutputLabel(key),
+ path,
+ });
+ };
+
+ const collectOutputs = (value: unknown, keyHint = "") => {
+ if (!value) {
+ return;
+ }
+ if (typeof value === "string") {
+ if (/path$/i.test(keyHint)) {
+ addOutput(keyHint, value);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ if (keyHint === "artifacts") {
+ value.forEach((item) => {
+ if (typeof item === "string") {
+ addOutput(keyHint, item);
+ }
+ });
+ return;
+ }
+ value.forEach((item) => collectOutputs(item, keyHint));
+ return;
+ }
+ if (typeof value !== "object") {
+ return;
+ }
+
+ for (const [nestedKey, nestedValue] of Object.entries(
+ value as Record,
+ )) {
+ collectOutputs(nestedValue, nestedKey);
+ }
+ };
+
+ collectOutputs(parsed);
+ return outputs;
+ } catch {
+ return [];
+ }
+}
+
+function formatRunResultOutputLabel(key: string): string {
+ switch (key) {
+ case "outputPath":
+ return "输出文件";
+ case "screenshotPath":
+ return "截图文件";
+ case "artifacts":
+ return "导出文件";
+ default:
+ return key;
+ }
+}
+
+export function formatRunResultOutputName(path: string): string {
+ const segments = path.split(/[\\/]/).filter(Boolean);
+ return segments[segments.length - 1] || path;
+}
+
+export function formatRunResultText(resultText?: string): string {
+ const normalized = String(resultText || "").trim();
+ if (!normalized) {
+ return "";
+ }
+
+ try {
+ return JSON.stringify(JSON.parse(normalized), null, 2);
+ } catch {
+ return resultText || "";
+ }
+}
+
+export async function copyToClipboard(text: string, successMessage: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(successMessage);
+ } catch {
+ toast.error("复制失败");
+ }
+}
+
+export function buildDemoSelectorText(launchCode: string) {
+ return JSON.stringify(
+ {
+ code: launchCode,
+ },
+ null,
+ 2,
+ );
+}
+
+export function normalizeLaunchCode(value?: string): string {
+ return String(value || "")
+ .trim()
+ .toUpperCase();
+}
+
+export function isPlaceholderSelectorText(text: string): boolean {
+ const normalized = text.trim();
+ if (!normalized) {
+ return true;
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ const code =
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? String((parsed as Record).code || "")
+ .trim()
+ .toUpperCase()
+ : "";
+ return !code || code === "BUYER_001" || code === "DEMO_ABC123";
+ } catch {
+ return false;
+ }
+}
+
+function parseJsonObjectText(text: string): Record {
+ const normalized = text.trim();
+ if (!normalized) {
+ return {};
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ return parsed as Record;
+ } catch {
+ return {};
+ }
+}
+
+function mergeJsonObjectValues(
+ base: Record,
+ patch: Record,
+): Record {
+ const merged: Record = { ...base };
+ Object.entries(patch).forEach(([key, value]) => {
+ const baseValue = merged[key];
+ if (
+ baseValue &&
+ typeof baseValue === "object" &&
+ !Array.isArray(baseValue) &&
+ value &&
+ typeof value === "object" &&
+ !Array.isArray(value)
+ ) {
+ merged[key] = mergeJsonObjectValues(
+ baseValue as Record,
+ value as Record,
+ );
+ return;
+ }
+ merged[key] = value;
+ });
+ return merged;
+}
+
+export function buildPublicAPIVariableInputs(
+ config: AutomationScriptPublicAPIConfig,
+): RunVariableInputs {
+ return collectAutomationScriptPublicAPIVariableValues(config);
+}
+
+export function buildParamsTextFromPublicAPIRequest(
+ config: AutomationScriptPublicAPIConfig,
+ values: RunVariableInputs,
+ fallbackParamsText: string,
+): { paramsText: string; missingRequired: string[]; usedVariables: string[] } {
+ const resolvedBody = applyAutomationScriptPublicAPIVariables(
+ config.requestBodyText,
+ config.variables,
+ values,
+ );
+ const body = parseJsonObjectText(resolvedBody.bodyText);
+ const fallbackParams = parseJsonObjectText(fallbackParamsText);
+ const requestParams =
+ config.requestMode === "params-only"
+ ? body
+ : body.params && typeof body.params === "object" && !Array.isArray(body.params)
+ ? (body.params as Record)
+ : {};
+ const params =
+ Object.keys(requestParams).length > 0
+ ? mergeJsonObjectValues(fallbackParams, requestParams)
+ : fallbackParams;
+
+ return {
+ paramsText: JSON.stringify(params, null, 2),
+ missingRequired: resolvedBody.missingRequired,
+ usedVariables: resolvedBody.usedVariables,
+ };
+}
+
+export function isCodeOnlySelectorForLaunchCode(
+ text: string,
+ launchCode: string,
+): boolean {
+ const normalizedCode = normalizeLaunchCode(launchCode);
+ const normalizedText = text.trim();
+ if (!normalizedCode || !normalizedText) {
+ return false;
+ }
+
+ try {
+ const parsed = JSON.parse(normalizedText);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return false;
+ }
+
+ const entries = Object.entries(parsed as Record).filter(
+ ([, value]) => {
+ if (value == null) {
+ return false;
+ }
+ if (typeof value === "string") {
+ return value.trim() !== "";
+ }
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+ return true;
+ },
+ );
+ if (entries.length !== 1 || entries[0]?.[0] !== "code") {
+ return false;
+ }
+
+ return normalizeLaunchCode(String(entries[0][1] || "")) === normalizedCode;
+ } catch {
+ return false;
+ }
+}
+
+export function resolveInitialSelectorText(
+ script: AutomationScriptRecord,
+ demoSession: AutomationDemoSession,
+): string {
+ if (
+ script.targetConfig.mode !== "manual" &&
+ script.targetConfig.mode !== "existing"
+ ) {
+ return "";
+ }
+ if (script.targetConfig.mode === "existing") {
+ const selectorCode = normalizeLaunchCode(script.targetConfig.selector.code);
+ if (selectorCode) {
+ return buildDemoSelectorText(selectorCode);
+ }
+ }
+ const currentSelectorText = String(script.selectorText || "");
+ if (
+ script.type === "playwright-cdp" &&
+ isPlaceholderSelectorText(currentSelectorText) &&
+ demoSession.launchCode
+ ) {
+ return buildDemoSelectorText(demoSession.launchCode);
+ }
+ return currentSelectorText;
+}
+
+export function resolveRunnableSelectorText(
+ script: AutomationScriptRecord,
+ currentSelectorText: string,
+ demoSession: AutomationDemoSession,
+): string {
+ if (
+ script.targetConfig.mode !== "manual" &&
+ script.targetConfig.mode !== "existing"
+ ) {
+ return currentSelectorText;
+ }
+ if (
+ script.type === "playwright-cdp" &&
+ isPlaceholderSelectorText(currentSelectorText) &&
+ demoSession.launchCode
+ ) {
+ return buildDemoSelectorText(demoSession.launchCode);
+ }
+ return currentSelectorText;
+}
+
+export function resolveSelectorLaunchCode(text: string): string {
+ const normalized = text.trim();
+ if (!normalized) {
+ return "";
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return "";
+ }
+
+ return String((parsed as Record).code || "")
+ .trim()
+ .toUpperCase();
+ } catch {
+ return "";
+ }
+}
+
+export function filterSelectableProfiles(profiles: BrowserProfile[]): SelectableProfile[] {
+ return profiles
+ .flatMap((profile) => {
+ const launchCode = normalizeLaunchCode(profile.launchCode);
+ if (!launchCode) {
+ return [];
+ }
+ return [
+ {
+ ...profile,
+ launchCode,
+ },
+ ];
+ })
+ .sort((left, right) => {
+ if (left.running !== right.running) {
+ return left.running ? -1 : 1;
+ }
+ return left.profileName.localeCompare(right.profileName, "zh-CN");
+ });
+}
+
+export function resolvePreferredProfileId(
+ profiles: SelectableProfile[],
+ preferredProfileId: string,
+ preferredLaunchCode: string,
+): string {
+ const normalizedProfileId = String(preferredProfileId || "").trim();
+ const normalizedCode = normalizeLaunchCode(preferredLaunchCode);
+ if (!normalizedProfileId && !normalizedCode) {
+ return "";
+ }
+
+ if (normalizedProfileId) {
+ const matchedByID = profiles.find(
+ (profile) => profile.profileId === normalizedProfileId,
+ );
+ if (matchedByID) {
+ return matchedByID.profileId;
+ }
+ }
+
+ const matchedByCode = profiles.find(
+ (profile) => normalizeLaunchCode(profile.launchCode) === normalizedCode,
+ );
+ if (matchedByCode) {
+ return matchedByCode.profileId;
+ }
+
+ return "";
+}
+
+export function buildSelectableProfileOptions(profiles: SelectableProfile[]) {
+ return profiles.map((profile) => ({
+ value: profile.profileId,
+ label: `${profile.launchCode} · ${profile.profileName} · ${formatSelectableProfileStatus(profile)}`,
+ }));
+}
+
+function formatSelectableProfileStatus(profile: SelectableProfile): string {
+ if (profile.running && profile.debugReady && profile.debugPort > 0) {
+ return "可连接";
+ }
+ if (profile.running) {
+ return "启动中";
+ }
+ return "未启动,执行时自动启动";
+}
+
+export function sortTemplateProfiles(profiles: BrowserProfile[]) {
+ return [...profiles].sort((left, right) =>
+ left.profileName.localeCompare(right.profileName, "zh-CN"),
+ );
+}
+
+export function buildTemplateProfileOptions(profiles: BrowserProfile[]) {
+ return profiles.map((profile) => ({
+ value: profile.profileId,
+ label: [profile.launchCode || "", profile.profileName || profile.profileId]
+ .filter(Boolean)
+ .join(" · "),
+ }));
+}
+
diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx
index 34da1598..58d614f9 100644
--- a/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx
+++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.tsx
@@ -1,532 +1,37 @@
import { useEffect, useMemo, useState } from "react";
-import { Copy, FileText, FolderOpen, Play } from "lucide-react";
import { useNavigate } from "react-router-dom";
-import {
- Badge,
- Button,
- FormItem,
- Input,
- Modal,
- Textarea,
- toast,
-} from "../../../shared/components";
-import { fetchBrowserProfiles, fetchGroups, openCorePath } from "../api";
-import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
+import { toast } from "../../../shared/components";
+import { openCorePath } from "../api";
import { runAutomationScript } from "../automationScriptApi";
import {
DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
applyAutomationScriptPublicAPIVariables,
- collectAutomationScriptPublicAPIVariableValues,
createAutomationScriptTargetSelector,
- describeAutomationScriptTargetConfig,
- getAutomationScriptTypeLabel,
normalizeAutomationScriptTargetSelector,
resolveAutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIConfig,
- type AutomationScriptRecord,
type AutomationScriptRunRecord,
type AutomationScriptTargetSelector,
} from "../automationScripts";
-import { TargetSelectorEditor } from "../pages/automationScriptDetail/shared";
-import {
- buildGroupOptions,
- buildProfileSuggestions,
-} from "../pages/automationScriptDetail/helpers";
-import {
- type AutomationDemoSession,
-} from "../demoSession";
import { useAutomationDemoSession } from "../hooks/useAutomationDemoSession";
-import type { BrowserGroupWithCount, BrowserProfile } from "../types";
-
-type DemoPreparationMode = "select" | "create";
-
-type SelectableProfile = BrowserProfile & {
- launchCode: string;
-};
-
-interface DemoCreateDraft {
- profileName: string;
- templateProfileId: string;
-}
-
-interface ResultOutputEntry {
- key: string;
- label: string;
- path: string;
-}
-
-interface AutomationScriptRunModalProps {
- open: boolean;
- script: AutomationScriptRecord | null;
- dirty?: boolean;
- onClose: () => void;
-}
-
-type RunVariableInputs = Record;
-
-const DEFAULT_DEMO_CREATE_DRAFT: DemoCreateDraft = {
- profileName: "",
- templateProfileId: "",
-};
-
-function validateJsonObjectText(
- text: string,
- label: string,
- required: boolean,
-): string {
- const normalized = text.trim();
- if (!normalized) {
- return required ? `${label}不能为空` : "";
- }
-
- try {
- const parsed = JSON.parse(normalized);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return `${label}必须是 JSON 对象`;
- }
- return "";
- } catch {
- return `${label}不是合法 JSON`;
- }
-}
-
-function formatDateTime(value?: string): string {
- if (!value) {
- return "-";
- }
-
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) {
- return value;
- }
-
- return date.toLocaleString("zh-CN", { hour12: false });
-}
-
-function formatDuration(durationMs?: number): string {
- if (!durationMs || durationMs <= 0) {
- return "-";
- }
- if (durationMs < 1000) {
- return `${durationMs} ms`;
- }
- return `${(durationMs / 1000).toFixed(2)} s`;
-}
-
-function parseRunResultOutputs(resultText?: string): ResultOutputEntry[] {
- const normalized = String(resultText || "").trim();
- if (!normalized) {
- return [];
- }
-
- try {
- const parsed = JSON.parse(normalized);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return [];
- }
-
- const seen = new Set();
- const outputs: ResultOutputEntry[] = [];
-
- const addOutput = (key: string, value: string) => {
- const path = value.trim();
- if (!path || seen.has(path)) {
- return;
- }
- seen.add(path);
- outputs.push({
- key,
- label: formatRunResultOutputLabel(key),
- path,
- });
- };
-
- const collectOutputs = (value: unknown, keyHint = "") => {
- if (!value) {
- return;
- }
- if (typeof value === "string") {
- if (/path$/i.test(keyHint)) {
- addOutput(keyHint, value);
- }
- return;
- }
- if (Array.isArray(value)) {
- if (keyHint === "artifacts") {
- value.forEach((item) => {
- if (typeof item === "string") {
- addOutput(keyHint, item);
- }
- });
- return;
- }
- value.forEach((item) => collectOutputs(item, keyHint));
- return;
- }
- if (typeof value !== "object") {
- return;
- }
-
- for (const [nestedKey, nestedValue] of Object.entries(
- value as Record,
- )) {
- collectOutputs(nestedValue, nestedKey);
- }
- };
-
- collectOutputs(parsed);
- return outputs;
- } catch {
- return [];
- }
-}
-
-function formatRunResultOutputLabel(key: string): string {
- switch (key) {
- case "outputPath":
- return "输出文件";
- case "screenshotPath":
- return "截图文件";
- case "artifacts":
- return "导出文件";
- default:
- return key;
- }
-}
-
-function formatRunResultOutputName(path: string): string {
- const segments = path.split(/[\\/]/).filter(Boolean);
- return segments[segments.length - 1] || path;
-}
-
-function formatRunResultText(resultText?: string): string {
- const normalized = String(resultText || "").trim();
- if (!normalized) {
- return "";
- }
-
- try {
- return JSON.stringify(JSON.parse(normalized), null, 2);
- } catch {
- return resultText || "";
- }
-}
-
-async function copyToClipboard(text: string, successMessage: string) {
- try {
- await navigator.clipboard.writeText(text);
- toast.success(successMessage);
- } catch {
- toast.error("复制失败");
- }
-}
-
-function buildDemoSelectorText(launchCode: string) {
- return JSON.stringify(
- {
- code: launchCode,
- },
- null,
- 2,
- );
-}
-
-function normalizeLaunchCode(value?: string): string {
- return String(value || "")
- .trim()
- .toUpperCase();
-}
-
-function isPlaceholderSelectorText(text: string): boolean {
- const normalized = text.trim();
- if (!normalized) {
- return true;
- }
-
- try {
- const parsed = JSON.parse(normalized);
- const code =
- parsed && typeof parsed === "object" && !Array.isArray(parsed)
- ? String((parsed as Record).code || "")
- .trim()
- .toUpperCase()
- : "";
- return !code || code === "BUYER_001" || code === "DEMO_ABC123";
- } catch {
- return false;
- }
-}
-
-function parseJsonObjectText(text: string): Record {
- const normalized = text.trim();
- if (!normalized) {
- return {};
- }
-
- try {
- const parsed = JSON.parse(normalized);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return {};
- }
- return parsed as Record;
- } catch {
- return {};
- }
-}
-
-function mergeJsonObjectValues(
- base: Record,
- patch: Record,
-): Record {
- const merged: Record = { ...base };
- Object.entries(patch).forEach(([key, value]) => {
- const baseValue = merged[key];
- if (
- baseValue &&
- typeof baseValue === "object" &&
- !Array.isArray(baseValue) &&
- value &&
- typeof value === "object" &&
- !Array.isArray(value)
- ) {
- merged[key] = mergeJsonObjectValues(
- baseValue as Record,
- value as Record,
- );
- return;
- }
- merged[key] = value;
- });
- return merged;
-}
-
-function buildPublicAPIVariableInputs(
- config: AutomationScriptPublicAPIConfig,
-): RunVariableInputs {
- return collectAutomationScriptPublicAPIVariableValues(config);
-}
-
-function buildParamsTextFromPublicAPIRequest(
- config: AutomationScriptPublicAPIConfig,
- values: RunVariableInputs,
- fallbackParamsText: string,
-): { paramsText: string; missingRequired: string[]; usedVariables: string[] } {
- const resolvedBody = applyAutomationScriptPublicAPIVariables(
- config.requestBodyText,
- config.variables,
- values,
- );
- const body = parseJsonObjectText(resolvedBody.bodyText);
- const fallbackParams = parseJsonObjectText(fallbackParamsText);
- const requestParams =
- config.requestMode === "params-only"
- ? body
- : body.params && typeof body.params === "object" && !Array.isArray(body.params)
- ? (body.params as Record)
- : {};
- const params =
- Object.keys(requestParams).length > 0
- ? mergeJsonObjectValues(fallbackParams, requestParams)
- : fallbackParams;
-
- return {
- paramsText: JSON.stringify(params, null, 2),
- missingRequired: resolvedBody.missingRequired,
- usedVariables: resolvedBody.usedVariables,
- };
-}
-
-function isCodeOnlySelectorForLaunchCode(
- text: string,
- launchCode: string,
-): boolean {
- const normalizedCode = normalizeLaunchCode(launchCode);
- const normalizedText = text.trim();
- if (!normalizedCode || !normalizedText) {
- return false;
- }
-
- try {
- const parsed = JSON.parse(normalizedText);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return false;
- }
-
- const entries = Object.entries(parsed as Record).filter(
- ([, value]) => {
- if (value == null) {
- return false;
- }
- if (typeof value === "string") {
- return value.trim() !== "";
- }
- if (Array.isArray(value)) {
- return value.length > 0;
- }
- return true;
- },
- );
- if (entries.length !== 1 || entries[0]?.[0] !== "code") {
- return false;
- }
-
- return normalizeLaunchCode(String(entries[0][1] || "")) === normalizedCode;
- } catch {
- return false;
- }
-}
-
-function resolveInitialSelectorText(
- script: AutomationScriptRecord,
- demoSession: AutomationDemoSession,
-): string {
- if (
- script.targetConfig.mode !== "manual" &&
- script.targetConfig.mode !== "existing"
- ) {
- return "";
- }
- if (script.targetConfig.mode === "existing") {
- const selectorCode = normalizeLaunchCode(script.targetConfig.selector.code);
- if (selectorCode) {
- return buildDemoSelectorText(selectorCode);
- }
- }
- const currentSelectorText = String(script.selectorText || "");
- if (
- script.type === "playwright-cdp" &&
- isPlaceholderSelectorText(currentSelectorText) &&
- demoSession.launchCode
- ) {
- return buildDemoSelectorText(demoSession.launchCode);
- }
- return currentSelectorText;
-}
-
-function resolveRunnableSelectorText(
- script: AutomationScriptRecord,
- currentSelectorText: string,
- demoSession: AutomationDemoSession,
-): string {
- if (
- script.targetConfig.mode !== "manual" &&
- script.targetConfig.mode !== "existing"
- ) {
- return currentSelectorText;
- }
- if (
- script.type === "playwright-cdp" &&
- isPlaceholderSelectorText(currentSelectorText) &&
- demoSession.launchCode
- ) {
- return buildDemoSelectorText(demoSession.launchCode);
- }
- return currentSelectorText;
-}
-
-function resolveSelectorLaunchCode(text: string): string {
- const normalized = text.trim();
- if (!normalized) {
- return "";
- }
-
- try {
- const parsed = JSON.parse(normalized);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return "";
- }
-
- return String((parsed as Record).code || "")
- .trim()
- .toUpperCase();
- } catch {
- return "";
- }
-}
-
-function filterSelectableProfiles(profiles: BrowserProfile[]): SelectableProfile[] {
- return profiles
- .flatMap((profile) => {
- const launchCode = normalizeLaunchCode(profile.launchCode);
- if (!launchCode) {
- return [];
- }
- return [
- {
- ...profile,
- launchCode,
- },
- ];
- })
- .sort((left, right) => {
- if (left.running !== right.running) {
- return left.running ? -1 : 1;
- }
- return left.profileName.localeCompare(right.profileName, "zh-CN");
- });
-}
-
-function resolvePreferredProfileId(
- profiles: SelectableProfile[],
- preferredProfileId: string,
- preferredLaunchCode: string,
-): string {
- const normalizedProfileId = String(preferredProfileId || "").trim();
- const normalizedCode = normalizeLaunchCode(preferredLaunchCode);
- if (!normalizedProfileId && !normalizedCode) {
- return "";
- }
-
- if (normalizedProfileId) {
- const matchedByID = profiles.find(
- (profile) => profile.profileId === normalizedProfileId,
- );
- if (matchedByID) {
- return matchedByID.profileId;
- }
- }
-
- const matchedByCode = profiles.find(
- (profile) => normalizeLaunchCode(profile.launchCode) === normalizedCode,
- );
- if (matchedByCode) {
- return matchedByCode.profileId;
- }
-
- return "";
-}
-
-function buildSelectableProfileOptions(profiles: SelectableProfile[]) {
- return profiles.map((profile) => ({
- value: profile.profileId,
- label: `${profile.launchCode} · ${profile.profileName} · ${formatSelectableProfileStatus(profile)}`,
- }));
-}
-
-function formatSelectableProfileStatus(profile: SelectableProfile): string {
- if (profile.running && profile.debugReady && profile.debugPort > 0) {
- return "可连接";
- }
- if (profile.running) {
- return "启动中";
- }
- return "未启动,执行时自动启动";
-}
-
-function sortTemplateProfiles(profiles: BrowserProfile[]) {
- return [...profiles].sort((left, right) =>
- left.profileName.localeCompare(right.profileName, "zh-CN"),
- );
-}
-
-function buildTemplateProfileOptions(profiles: BrowserProfile[]) {
- return profiles.map((profile) => ({
- value: profile.profileId,
- label: [profile.launchCode || "", profile.profileName || profile.profileId]
- .filter(Boolean)
- .join(" · "),
- }));
-}
+import { useAutomationScriptRunProfiles } from "./useAutomationScriptRunProfiles";
+import type {
+ AutomationScriptRunModalProps,
+ RunVariableInputs,
+} from "./AutomationScriptRunModal.types";
+import {
+ buildDemoSelectorText,
+ buildParamsTextFromPublicAPIRequest,
+ buildPublicAPIVariableInputs,
+ buildSelectableProfileOptions,
+ buildTemplateProfileOptions,
+ isPlaceholderSelectorText,
+ resolveInitialSelectorText,
+ resolveRunnableSelectorText,
+ resolveSelectorLaunchCode,
+ validateJsonObjectText,
+} from "./AutomationScriptRunModal.helpers";
+import { AutomationScriptRunModalView } from "./AutomationScriptRunModalView";
export function AutomationScriptRunModal({
open,
@@ -543,18 +48,6 @@ export function AutomationScriptRunModal({
const [lastRun, setLastRun] = useState(
null,
);
- const [demoMode, setDemoMode] = useState("select");
- const [availableProfiles, setAvailableProfiles] = useState(
- [],
- );
- const [templateProfiles, setTemplateProfiles] = useState([]);
- const [allProfiles, setAllProfiles] = useState([]);
- const [groups, setGroups] = useState([]);
- const [profilesLoading, setProfilesLoading] = useState(false);
- const [selectedProfileId, setSelectedProfileId] = useState("");
- const [createDraft, setCreateDraft] = useState(
- DEFAULT_DEMO_CREATE_DRAFT,
- );
const [rotateSelector, setRotateSelector] =
useState(() =>
createAutomationScriptTargetSelector(),
@@ -564,16 +57,6 @@ export function AutomationScriptRunModal({
setDemoSession,
reloadDemoSession,
} = useAutomationDemoSession({ enabled: open });
-
- const selectedProfile =
- availableProfiles.find((profile) => profile.profileId === selectedProfileId) ||
- null;
- const selectorDetachedFromSelectedProfile =
- demoMode === "select" &&
- !!selectedProfile &&
- !!selectorText.trim() &&
- !isPlaceholderSelectorText(selectorText) &&
- !isCodeOnlySelectorForLaunchCode(selectorText, selectedProfile.launchCode);
const isDualInstanceRuntimeScript =
script?.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID;
const isManualTargetMode =
@@ -583,7 +66,6 @@ export function AutomationScriptRunModal({
!!script && !isManualTargetMode;
const showsSelectorInput =
!!script && !usesStoredTargetConfig && !isDualInstanceRuntimeScript;
- const selectedLaunchCode = resolveSelectorLaunchCode(selectorText);
const publicAPIConfig = useMemo(
() => (script ? resolveAutomationScriptPublicAPIConfig(script) : null),
[script],
@@ -607,31 +89,40 @@ export function AutomationScriptRunModal({
publicAPIVariables.some(
(variable) => !usedPublicAPIVariableNames.has(variable.name),
);
- const codeSuggestions = buildProfileSuggestions(
- allProfiles,
- (profile) => profile.launchCode,
- (profile) =>
- profile.profileName
- ? `${profile.launchCode || "未设 Code"} · ${profile.profileName}`
- : profile.profileId,
- );
- const profileIdSuggestions = buildProfileSuggestions(
- allProfiles,
- (profile) => profile.profileId,
- (profile) =>
- profile.launchCode
- ? `${profile.launchCode} · ${profile.profileName || profile.profileId}`
- : profile.profileName || profile.profileId,
- );
- const profileNameSuggestions = buildProfileSuggestions(
- allProfiles,
- (profile) => profile.profileName,
- (profile) =>
- profile.launchCode
- ? `${profile.launchCode} · ${profile.profileId}`
- : profile.profileId,
- );
- const groupOptions = [{ value: "", label: "不限制" }, ...buildGroupOptions(groups)];
+ const {
+ demoMode,
+ setDemoMode,
+ availableProfiles,
+ templateProfiles,
+ profilesLoading,
+ selectedProfileId,
+ setSelectedProfileId,
+ createDraft,
+ setCreateDraft,
+ selectedProfile,
+ selectorDetachedFromSelectedProfile,
+ selectedLaunchCode,
+ codeSuggestions,
+ profileIdSuggestions,
+ profileNameSuggestions,
+ groupOptions,
+ syncDemoSessionFromProfile,
+ handleSelectedProfileChange,
+ handleLaunchCodeChange,
+ handleSelectorTextChange,
+ handleRestoreSelectedProfileSelector,
+ } = useAutomationScriptRunProfiles({
+ open,
+ script,
+ isManualTargetMode,
+ usesStoredTargetConfig,
+ selectorText,
+ setSelectorText,
+ demoSession,
+ setDemoSession,
+ reloadDemoSession,
+ });
+
const syncParamsFromPublicAPIVariables = (
config: AutomationScriptPublicAPIConfig,
inputs: RunVariableInputs,
@@ -698,126 +189,6 @@ export function AutomationScriptRunModal({
}`
: '{"startUrls":["https://example.com"]}';
- const syncDemoSessionFromProfile = (
- profile: SelectableProfile,
- actionLabel: string,
- ) => {
- setDemoSession((current) => ({
- ...current,
- profileId: profile.profileId,
- profileName: profile.profileName,
- launchCode: profile.launchCode,
- cdpUrl:
- profile.running && profile.debugReady && profile.debugPort > 0
- ? `http://127.0.0.1:${profile.debugPort}`
- : "",
- debugPort:
- profile.running && profile.debugReady && profile.debugPort > 0
- ? profile.debugPort
- : 0,
- lastAction: actionLabel,
- }));
- };
-
- const refreshSelectableProfiles = async (
- preferredProfileId = "",
- preferredLaunchCode = "",
- showError = false,
- ) => {
- setProfilesLoading(true);
- try {
- const allProfiles = await fetchBrowserProfiles();
- setAllProfiles(allProfiles);
- const profiles = filterSelectableProfiles(allProfiles);
- const nextSelectedProfileId =
- resolvePreferredProfileId(
- profiles,
- preferredProfileId,
- preferredLaunchCode,
- ) ||
- (selectedProfileId &&
- profiles.some((profile) => profile.profileId === selectedProfileId)
- ? selectedProfileId
- : isManualTargetMode
- ? ""
- : profiles[0]?.profileId || "");
- const nextSelectedProfile =
- profiles.find((profile) => profile.profileId === nextSelectedProfileId) ||
- null;
-
- setAvailableProfiles(profiles);
- setTemplateProfiles(sortTemplateProfiles(allProfiles));
- setSelectedProfileId(nextSelectedProfileId);
- if (demoMode === "select" && nextSelectedProfile) {
- const keepManualSelector =
- !!selectorText.trim() &&
- !isPlaceholderSelectorText(selectorText) &&
- !isCodeOnlySelectorForLaunchCode(
- selectorText,
- nextSelectedProfile.launchCode,
- );
- const nextSelectorText = buildDemoSelectorText(
- nextSelectedProfile.launchCode,
- );
- if (
- !keepManualSelector &&
- resolveSelectorLaunchCode(selectorText) !==
- nextSelectedProfile.launchCode
- ) {
- setSelectorText(nextSelectorText);
- }
- if (!keepManualSelector) {
- syncDemoSessionFromProfile(nextSelectedProfile, "选择实例");
- }
- }
- setCreateDraft((current) => {
- if (
- current.templateProfileId &&
- allProfiles.some((profile) => profile.profileId === current.templateProfileId)
- ) {
- return current;
- }
- return {
- ...current,
- templateProfileId: allProfiles[0]?.profileId || "",
- };
- });
- if (!profiles.length && !isManualTargetMode) {
- setDemoMode("create");
- }
- } catch (error: unknown) {
- if (showError) {
- const message =
- error instanceof Error ? error.message : "实例列表刷新失败";
- toast.error(message);
- }
- } finally {
- setProfilesLoading(false);
- }
- };
-
- useEffect(() => {
- if (!open) {
- return;
- }
- let disposed = false;
- void fetchGroups().then(
- (items) => {
- if (!disposed) {
- setGroups(items || []);
- }
- },
- () => {
- if (!disposed) {
- setGroups([]);
- }
- },
- );
- return () => {
- disposed = true;
- };
- }, [open]);
-
useEffect(() => {
if (!open || !script) {
return;
@@ -856,36 +227,6 @@ export function AutomationScriptRunModal({
);
}, [open, script, publicAPIConfig]);
- useEffect(() => {
- if (!open || !script) {
- setAvailableProfiles([]);
- setSelectedProfileId("");
- return;
- }
-
- const nextDemoSession = reloadDemoSession();
- const nextSelectorText = resolveInitialSelectorText(script, nextDemoSession);
- void refreshSelectableProfiles(
- script.targetConfig.selector.profileId || nextDemoSession.profileId,
- resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode,
- false,
- );
- }, [open, script, usesStoredTargetConfig]);
-
- useEffect(() => {
- if (!open || !script || script.type !== "playwright-cdp") {
- return;
- }
- if (usesStoredTargetConfig) {
- return;
- }
- if (demoMode !== "select") {
- return;
- }
-
- void refreshSelectableProfiles("", demoSession.launchCode, false);
- }, [demoMode, demoSession.launchCode, open, script, usesStoredTargetConfig]);
-
const handleClose = () => {
if (running || demoBusy) {
return;
@@ -979,49 +320,6 @@ export function AutomationScriptRunModal({
}
};
- const handleSelectedProfileChange = (profileId: string) => {
- setSelectedProfileId(profileId);
- const profile =
- availableProfiles.find((item) => item.profileId === profileId) || null;
- if (!profile) {
- return;
- }
-
- setSelectorText(buildDemoSelectorText(profile.launchCode));
- syncDemoSessionFromProfile(profile, "选择实例");
- };
-
- const handleLaunchCodeChange = (code: string) => {
- const launchCode = normalizeLaunchCode(code);
- setSelectorText(launchCode ? buildDemoSelectorText(launchCode) : "");
- const profile =
- availableProfiles.find((item) => item.launchCode === launchCode) || null;
- setSelectedProfileId(profile?.profileId || "");
- if (profile) {
- syncDemoSessionFromProfile(profile, "填写实例 Code");
- }
- };
-
- const handleSelectorTextChange = (value: string) => {
- setSelectorText(value);
- const launchCode = resolveSelectorLaunchCode(value);
- const profile =
- availableProfiles.find((item) => item.launchCode === launchCode) || null;
- setSelectedProfileId(profile?.profileId || "");
- if (profile) {
- syncDemoSessionFromProfile(profile, "填写 selector");
- }
- };
-
- const handleRestoreSelectedProfileSelector = () => {
- if (!selectedProfile) {
- return;
- }
-
- setSelectorText(buildDemoSelectorText(selectedProfile.launchCode));
- syncDemoSessionFromProfile(selectedProfile, "选择实例");
- };
-
const handleRun = async () => {
if (!script) {
return;
@@ -1130,364 +428,54 @@ export function AutomationScriptRunModal({
script.type === "playwright-cdp" && !usesStoredTargetConfig;
const selectableProfileOptions = buildSelectableProfileOptions(availableProfiles);
const templateProfileOptions = buildTemplateProfileOptions(templateProfiles);
- const resultOutputs = parseRunResultOutputs(lastRun?.resultText);
- const formattedResultText = formatRunResultText(lastRun?.resultText);
+ const viewProps = {
+ open,
+ dirty,
+ script,
+ running,
+ demoBusy,
+ launchApiExecutable,
+ showDemoProfilePicker,
+ isManualTargetMode,
+ usesStoredTargetConfig,
+ isDualInstanceRuntimeScript,
+ selectorDetachedFromSelectedProfile,
+ showsSelectorInput,
+ hasPublicAPIVariables,
+ hasUnusedPublicAPIVariables,
+ profilesLoading,
+ selectedProfileId,
+ selectedProfile,
+ selectedLaunchCode,
+ selectorText,
+ paramsText,
+ paramsFieldLabel,
+ paramsPlaceholder,
+ demoMode,
+ createDraft,
+ rotateSelector,
+ variableInputs,
+ publicAPIVariables,
+ selectableProfileOptions,
+ templateProfileOptions,
+ codeSuggestions,
+ profileIdSuggestions,
+ profileNameSuggestions,
+ groupOptions,
+ lastRun,
+ handleClose,
+ handleOpenScriptDetail,
+ handlePrimaryAction,
+ handleSelectedProfileChange,
+ handleLaunchCodeChange,
+ handleRestoreSelectedProfileSelector,
+ handleSelectorTextChange,
+ handleOpenOutputPath,
+ setCreateDraft,
+ updateVariableInput,
+ updateParamsText,
+ updateRotateSelector,
+ };
- return (
-
-
-
- >
- }
- >
-
-
-
-
-
- {script.name}
-
-
- {formatDateTime(script.updatedAt)}
-
-
-
-
- {getAutomationScriptTypeLabel(script.type)}
-
-
- {script.status === "ready"
- ? "可用"
- : script.status === "disabled"
- ? "停用"
- : "草稿"}
-
-
-
-
-
-
- {dirty && (
-
- {isDualInstanceRuntimeScript
- ? "当前详情页还有未保存修改。本次执行只使用弹窗里的启动配置,不会自动保存页面内容。"
- : "当前详情页还有未保存修改。本次执行只使用弹窗里的 selector / params,不会自动保存页面内容。"}
-
- )}
-
- {usesStoredTargetConfig && (
-
-
- {describeAutomationScriptTargetConfig(script.targetConfig)}
-
-
- 本次执行沿用脚本配置的实例策略,只填写本策略需要的执行配置。
-
-
- )}
-
- {showDemoProfilePicker && isManualTargetMode ? (
-
- 当前 selector 已手动修改,执行以下方 JSON 为准。
-
-
- ) : null
- }
- />
- ) : null}
-
- {script.targetConfig.mode === "create" ? (
-
- setCreateDraft((current) => ({
- ...current,
- profileName,
- }))
- }
- onTemplateChange={(templateProfileId) =>
- setCreateDraft((current) => ({
- ...current,
- templateProfileId,
- }))
- }
- />
- ) : null}
-
- {script.targetConfig.mode === "rotate" ? (
-
- }
- />
- ) : null}
-
- {showDemoProfilePicker && !isManualTargetMode && demoMode === "select" ? (
-
- 当前 selector 已手动修改,执行以下方 JSON 为准。
-
-
- ) : null
- }
- />
- ) : null}
-
- {script.status === "disabled" ? (
-
- 该脚本当前处于停用状态,先把状态切回可用再执行。
-
- ) : (
-
- {hasPublicAPIVariables ? (
-
-
-
- 接口变量
-
- {hasUnusedPublicAPIVariables ? (
-
- 未引用变量不生效
-
- ) : null}
-
-
- {publicAPIVariables.map((variable) => (
-
-
- updateVariableInput(variable.name, event.target.value)
- }
- placeholder={variable.description || variable.defaultValue}
- className="h-10 rounded-lg"
- disabled={running || demoBusy}
- />
-
- ))}
-
-
- ) : null}
-
-
- {showsSelectorInput && (
-
-
- )}
-
-
-
-
-
- )}
-
- {lastRun && (
-
-
-
-
- {lastRun.status === "success" ? "执行成功" : "执行失败"}
-
-
- {lastRun.summary || "执行已完成"}
-
-
-
- {formatDateTime(lastRun.startedAt)} ·{" "}
- {formatDuration(lastRun.durationMs)}
-
-
-
- {lastRun.error && (
-
- {lastRun.error}
-
- )}
-
- {lastRun.resultText && (
-
-
-
- 结果输出
-
-
-
-
- {resultOutputs.length > 0 && (
-
-
- {resultOutputs.map((output) => (
-
-
-
- {output.label} · {formatRunResultOutputName(output.path)}
-
-
- {output.path}
-
-
-
-
- ))}
-
-
- )}
-
- )}
-
- )}
-
-
- );
+ return ;
}
diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModal.types.ts b/frontend/src/modules/browser/components/AutomationScriptRunModal.types.ts
new file mode 100644
index 00000000..ef70ef63
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptRunModal.types.ts
@@ -0,0 +1,34 @@
+import type { AutomationScriptRecord } from "../automationScripts";
+import type { BrowserProfile } from "../types";
+
+export type DemoPreparationMode = "select" | "create";
+
+export type SelectableProfile = BrowserProfile & {
+ launchCode: string;
+};
+
+export interface DemoCreateDraft {
+ profileName: string;
+ templateProfileId: string;
+}
+
+export interface ResultOutputEntry {
+ key: string;
+ label: string;
+ path: string;
+}
+
+export interface AutomationScriptRunModalProps {
+ open: boolean;
+ script: AutomationScriptRecord | null;
+ dirty?: boolean;
+ onClose: () => void;
+}
+
+export type RunVariableInputs = Record;
+
+export const DEFAULT_DEMO_CREATE_DRAFT: DemoCreateDraft = {
+ profileName: "",
+ templateProfileId: "",
+};
+
diff --git a/frontend/src/modules/browser/components/AutomationScriptRunModalView.tsx b/frontend/src/modules/browser/components/AutomationScriptRunModalView.tsx
new file mode 100644
index 00000000..c59a071f
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptRunModalView.tsx
@@ -0,0 +1,404 @@
+import type { Dispatch, SetStateAction } from "react";
+import { FileText, Play } from "lucide-react";
+import {
+ Badge,
+ Button,
+ FormItem,
+ Input,
+ Modal,
+ Textarea,
+} from "../../../shared/components";
+import {
+ describeAutomationScriptTargetConfig,
+ getAutomationScriptTypeLabel,
+ type AutomationScriptRecord,
+ type AutomationScriptRunRecord,
+ type AutomationScriptTargetSelector,
+} from "../automationScripts";
+import { TargetSelectorEditor } from "../pages/automationScriptDetail/shared";
+import type { SelectorSuggestion } from "../pages/automationScriptDetail/helpers";
+import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
+import { AutomationScriptRunResultPanel } from "./AutomationScriptRunResultPanel";
+import type { DemoCreateDraft, DemoPreparationMode, RunVariableInputs, SelectableProfile } from "./AutomationScriptRunModal.types";
+import { formatDateTime } from "./AutomationScriptRunModal.helpers";
+
+type Option = { value: string; label: string };
+
+interface AutomationScriptRunModalViewProps {
+ open: boolean;
+ dirty: boolean;
+ script: AutomationScriptRecord;
+ running: boolean;
+ demoBusy: boolean;
+ launchApiExecutable: boolean;
+ showDemoProfilePicker: boolean;
+ isManualTargetMode: boolean;
+ usesStoredTargetConfig: boolean;
+ isDualInstanceRuntimeScript: boolean;
+ selectorDetachedFromSelectedProfile: boolean;
+ showsSelectorInput: boolean;
+ hasPublicAPIVariables: boolean;
+ hasUnusedPublicAPIVariables: boolean;
+ profilesLoading: boolean;
+ selectedProfileId: string;
+ selectedProfile: SelectableProfile | null;
+ selectedLaunchCode: string;
+ selectorText: string;
+ paramsText: string;
+ paramsFieldLabel: string;
+ paramsPlaceholder: string;
+ demoMode: DemoPreparationMode;
+ createDraft: DemoCreateDraft;
+ rotateSelector: AutomationScriptTargetSelector;
+ variableInputs: RunVariableInputs;
+ publicAPIVariables: Array<{ name: string; description?: string; defaultValue?: string }>;
+ selectableProfileOptions: Option[];
+ templateProfileOptions: Option[];
+ codeSuggestions: SelectorSuggestion[];
+ profileIdSuggestions: SelectorSuggestion[];
+ profileNameSuggestions: SelectorSuggestion[];
+ groupOptions: Option[];
+ lastRun: AutomationScriptRunRecord | null;
+ handleClose: () => void;
+ handleOpenScriptDetail: () => void;
+ handlePrimaryAction: () => Promise;
+ handleSelectedProfileChange: (profileId: string) => void;
+ handleLaunchCodeChange: (code: string) => void;
+ handleRestoreSelectedProfileSelector: () => void;
+ handleSelectorTextChange: (value: string) => void;
+ handleOpenOutputPath: (path: string) => Promise;
+ setCreateDraft: Dispatch>;
+ updateVariableInput: (name: string, value: string) => void;
+ updateParamsText: (value: string) => void;
+ updateRotateSelector: (patch: Partial) => void;
+}
+
+export function AutomationScriptRunModalView({
+ open,
+ dirty,
+ script,
+ running,
+ demoBusy,
+ launchApiExecutable,
+ showDemoProfilePicker,
+ isManualTargetMode,
+ usesStoredTargetConfig,
+ isDualInstanceRuntimeScript,
+ selectorDetachedFromSelectedProfile,
+ showsSelectorInput,
+ hasPublicAPIVariables,
+ hasUnusedPublicAPIVariables,
+ profilesLoading,
+ selectedProfileId,
+ selectedProfile,
+ selectedLaunchCode,
+ selectorText,
+ paramsText,
+ paramsFieldLabel,
+ paramsPlaceholder,
+ demoMode,
+ createDraft,
+ rotateSelector,
+ variableInputs,
+ publicAPIVariables,
+ selectableProfileOptions,
+ templateProfileOptions,
+ codeSuggestions,
+ profileIdSuggestions,
+ profileNameSuggestions,
+ groupOptions,
+ lastRun,
+ handleClose,
+ handleOpenScriptDetail,
+ handlePrimaryAction,
+ handleSelectedProfileChange,
+ handleLaunchCodeChange,
+ handleRestoreSelectedProfileSelector,
+ handleSelectorTextChange,
+ handleOpenOutputPath,
+ setCreateDraft,
+ updateVariableInput,
+ updateParamsText,
+ updateRotateSelector,
+}: AutomationScriptRunModalViewProps) {
+ return (
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+ {script.name}
+
+
+ {formatDateTime(script.updatedAt)}
+
+
+
+
+ {getAutomationScriptTypeLabel(script.type)}
+
+
+ {script.status === "ready"
+ ? "可用"
+ : script.status === "disabled"
+ ? "停用"
+ : "草稿"}
+
+
+
+
+
+
+ {dirty && (
+
+ {isDualInstanceRuntimeScript
+ ? "当前详情页还有未保存修改。本次执行只使用弹窗里的启动配置,不会自动保存页面内容。"
+ : "当前详情页还有未保存修改。本次执行只使用弹窗里的 selector / params,不会自动保存页面内容。"}
+
+ )}
+
+ {usesStoredTargetConfig && (
+
+
+ {describeAutomationScriptTargetConfig(script.targetConfig)}
+
+
+ 本次执行沿用脚本配置的实例策略,只填写本策略需要的执行配置。
+
+
+ )}
+
+ {showDemoProfilePicker && isManualTargetMode ? (
+
+ 当前 selector 已手动修改,执行以下方 JSON 为准。
+
+
+ ) : null
+ }
+ />
+ ) : null}
+
+ {script.targetConfig.mode === "create" ? (
+
+ setCreateDraft((current) => ({
+ ...current,
+ profileName,
+ }))
+ }
+ onTemplateChange={(templateProfileId) =>
+ setCreateDraft((current) => ({
+ ...current,
+ templateProfileId,
+ }))
+ }
+ />
+ ) : null}
+
+ {script.targetConfig.mode === "rotate" ? (
+
+ }
+ />
+ ) : null}
+
+ {showDemoProfilePicker && !isManualTargetMode && demoMode === "select" ? (
+
+ 当前 selector 已手动修改,执行以下方 JSON 为准。
+
+
+ ) : null
+ }
+ />
+ ) : null}
+
+ {script.status === "disabled" ? (
+
+ 该脚本当前处于停用状态,先把状态切回可用再执行。
+
+ ) : (
+
+ {hasPublicAPIVariables ? (
+
+
+
+ 接口变量
+
+ {hasUnusedPublicAPIVariables ? (
+
+ 未引用变量不生效
+
+ ) : null}
+
+
+ {publicAPIVariables.map((variable) => (
+
+
+ updateVariableInput(variable.name, event.target.value)
+ }
+ placeholder={variable.description || variable.defaultValue}
+ className="h-10 rounded-lg"
+ disabled={running || demoBusy}
+ />
+
+ ))}
+
+
+ ) : null}
+
+
+ {showsSelectorInput && (
+
+
+ )}
+
+
+
+
+
+ )}
+
+ {lastRun && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/modules/browser/components/AutomationScriptRunResultPanel.tsx b/frontend/src/modules/browser/components/AutomationScriptRunResultPanel.tsx
new file mode 100644
index 00000000..d09c3090
--- /dev/null
+++ b/frontend/src/modules/browser/components/AutomationScriptRunResultPanel.tsx
@@ -0,0 +1,98 @@
+import { Copy, FolderOpen } from "lucide-react";
+import { Badge, Button, Textarea } from "../../../shared/components";
+import type { AutomationScriptRunRecord } from "../automationScripts";
+import {
+ copyToClipboard,
+ formatDateTime,
+ formatDuration,
+ formatRunResultOutputName,
+ formatRunResultText,
+ parseRunResultOutputs,
+} from "./AutomationScriptRunModal.helpers";
+
+interface AutomationScriptRunResultPanelProps {
+ lastRun: AutomationScriptRunRecord;
+ handleOpenOutputPath: (path: string) => Promise;
+}
+
+export function AutomationScriptRunResultPanel({
+ lastRun,
+ handleOpenOutputPath,
+}: AutomationScriptRunResultPanelProps) {
+ const resultOutputs = parseRunResultOutputs(lastRun.resultText);
+ const formattedResultText = formatRunResultText(lastRun.resultText);
+
+ return (
+
+
+
+
+ {lastRun.status === "success" ? "执行成功" : "执行失败"}
+
+
+ {lastRun.summary || "执行已完成"}
+
+
+
+ {formatDateTime(lastRun.startedAt)} · {formatDuration(lastRun.durationMs)}
+
+
+
+ {lastRun.error && (
+
+ {lastRun.error}
+
+ )}
+
+ {lastRun.resultText && (
+
+
+
结果输出
+
+
+
+ {resultOutputs.length > 0 && (
+
+
+ {resultOutputs.map((output) => (
+
+
+
+ {output.label} · {formatRunResultOutputName(output.path)}
+
+
+ {output.path}
+
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/modules/browser/components/ProxyImportModal.helpers.ts b/frontend/src/modules/browser/components/ProxyImportModal.helpers.ts
new file mode 100644
index 00000000..33e9ee42
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyImportModal.helpers.ts
@@ -0,0 +1,441 @@
+import yaml from 'js-yaml'
+import type { BrowserProxy } from '../types'
+import { CHAIN_SOCKS5_PREFIX, type ChainImportForm, type ChainHopForm, type ChainSocks5Config, type ChainSocks5HopConfig, type ClashProxy, type DirectImportForm, type ImportCandidate, type ProxyDisplayInfo } from './ProxyImportModal.types'
+
+function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
+ const cfg = proxyConfig.trim()
+ if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
+ return null
+ }
+ const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
+ if (!encoded) {
+ return null
+ }
+
+ const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
+ if (!raw || typeof raw !== 'object') return null
+ const hop = raw as Record
+ const protocol = String(hop.protocol || '').trim().toLowerCase()
+ if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
+
+ const server = String(hop.server || '').trim()
+ if (!server) return null
+
+ const portVal = Number(hop.port || 0)
+ if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
+
+ const username = String(hop.username || '').trim()
+ const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
+ if (password && !username) return null
+
+ return {
+ protocol: protocol === 'http' ? 'http' : 'socks5',
+ server,
+ port: portVal,
+ username: username || undefined,
+ password: password || undefined,
+ }
+ }
+
+ try {
+ const decoded = decodeURIComponent(encoded)
+ const parsed = JSON.parse(decoded) as Record
+ const first = normalizeHop(parsed.first)
+ const second = normalizeHop(parsed.second)
+ if (!first || !second) return null
+
+ const localPortRaw = parsed.localPort
+ const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
+ ? 0
+ : Number(localPortRaw)
+ if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
+
+ return {
+ first,
+ second,
+ localPort: localPortNum > 0 ? localPortNum : undefined,
+ }
+ } catch {
+ return null
+ }
+}
+
+export function parseProxyInfo(proxyConfig: string): { type: string; server: string; port: number } {
+ const cfg = proxyConfig.trim()
+ if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
+
+ const chain = parseChainSocks5Config(cfg)
+ if (chain) {
+ return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
+ }
+
+ const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
+ if (urlMatch) {
+ const scheme = urlMatch[1].toLowerCase()
+ try {
+ const u = new URL(cfg)
+ return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 }
+ } catch {
+ return { type: scheme, server: '-', port: 0 }
+ }
+ }
+ try {
+ const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy
+ const proxy = Array.isArray(parsed) ? parsed[0] : parsed
+ return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 }
+ } catch {
+ return { type: '-', server: '-', port: 0 }
+ }
+}
+
+function proxyToYaml(proxy: ClashProxy): string {
+ return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim()
+}
+
+function quoteYamlScalar(value: string): string {
+ const v = value.trim()
+ if (!v) return "''"
+ return `'${v.replace(/'/g, "''")}'`
+}
+
+function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null {
+ const asArray = (input: unknown): ClashProxy[] => {
+ if (!Array.isArray(input)) return []
+ return input.filter((item): item is ClashProxy => !!item && typeof item === 'object')
+ }
+
+ if (Array.isArray(payload)) {
+ return asArray(payload)
+ }
+ if (!payload || typeof payload !== 'object') {
+ return null
+ }
+
+ const record = payload as Record
+ if (Array.isArray(record.proxies)) {
+ return asArray(record.proxies)
+ }
+ if (Array.isArray(record.proxy)) {
+ return asArray(record.proxy)
+ }
+ if (Array.isArray(record.Proxy)) {
+ return asArray(record.Proxy)
+ }
+ return null
+}
+
+function normalizeLooseClashImportText(raw: string): string {
+ const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim()
+ if (!normalizedNewline) return normalizedNewline
+
+ const lines = normalizedNewline.split('\n')
+ const fixedLines = lines.map(line => {
+ const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i)
+ if (!m) return line
+ const indent = m[1] || ''
+ const name = m[2] || ''
+ const tail = m[3] || ''
+ return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }`
+ })
+
+ const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line))
+ if (hasProxiesRoot) {
+ return fixedLines.join('\n')
+ }
+
+ const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line))
+ if (!looksLikeProxyList) {
+ return fixedLines.join('\n')
+ }
+
+ const indented = fixedLines.map(line => {
+ if (!line.trim()) return line
+ return ` ${line}`
+ })
+ return `proxies:\n${indented.join('\n')}`
+}
+
+export function parseClashImportText(raw: string): ClashProxy[] {
+ const input = raw.trim()
+ if (!input) {
+ throw new Error('请输入 YAML 内容')
+ }
+
+ const attempts = [input]
+ const normalized = normalizeLooseClashImportText(input)
+ if (normalized && normalized !== input) {
+ attempts.push(normalized)
+ }
+
+ let lastError: unknown = null
+ for (const text of attempts) {
+ try {
+ const parsed = yaml.load(text)
+ const proxies = normalizeImportedProxyArray(parsed)
+ if (proxies) {
+ return proxies
+ }
+ } catch (error) {
+ lastError = error
+ }
+ }
+
+ if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) {
+ throw new Error(String((lastError as { message?: string }).message || '解析失败'))
+ }
+ throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
+}
+
+function normalizeDirectProxyConfig(raw: string): string {
+ const trimmed = raw.trim()
+ if (!trimmed) return ''
+ if (/^socket:\/\//i.test(trimmed)) {
+ return trimmed.replace(/^socket:\/\//i, 'socks5://')
+ }
+ if (/^socks:\/\//i.test(trimmed)) {
+ return trimmed.replace(/^socks:\/\//i, 'socks5://')
+ }
+ return trimmed
+}
+
+function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
+ const name = rawName.trim()
+ const fallbackName = server
+ ? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
+ : `导入代理 ${index + 1}`
+ const finalName = name || fallbackName
+ return prefix ? `${prefix}-${finalName}` : finalName
+}
+
+function formatDirectProxyHost(raw: string): string {
+ const host = raw.trim()
+ if (!host) return ''
+ if (host.startsWith('[') && host.endsWith(']')) {
+ return host
+ }
+ return host.includes(':') ? `[${host}]` : host
+}
+
+export function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
+ const serverInput = form.server.trim()
+ if (!serverInput) {
+ throw new Error('请输入代理地址')
+ }
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
+ throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
+ }
+
+ const portInput = form.port.trim()
+ if (!portInput) {
+ throw new Error('请输入代理端口')
+ }
+ if (!/^\d+$/.test(portInput)) {
+ throw new Error('代理端口必须为数字')
+ }
+
+ const port = Number(portInput)
+ if (port < 1 || port > 65535) {
+ throw new Error('代理端口必须在 1-65535 之间')
+ }
+
+ const username = form.username.trim()
+ const password = form.password
+ if (password && !username) {
+ throw new Error('填写密码时请同时填写账号')
+ }
+
+ const auth = username
+ ? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
+ : ''
+ const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
+
+ let parsedURL: URL
+ try {
+ parsedURL = new URL(rawConfig)
+ } catch {
+ throw new Error('请输入有效的代理地址')
+ }
+
+ if (!parsedURL.hostname) {
+ throw new Error('请输入有效的代理地址')
+ }
+
+ const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
+ const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
+
+ return {
+ proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
+ proxyConfig: normalizedConfig,
+ }
+}
+
+export function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
+ const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
+ const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
+ const server = hop.server.trim()
+ if (!server) {
+ throw new Error(`请输入${label}代理地址`)
+ }
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
+ throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
+ }
+
+ const portInput = hop.port.trim()
+ if (!portInput) {
+ throw new Error(`请输入${label}代理端口`)
+ }
+ if (!/^\d+$/.test(portInput)) {
+ throw new Error(`${label}代理端口必须为数字`)
+ }
+
+ const port = Number(portInput)
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`${label}代理端口必须在 1-65535 之间`)
+ }
+
+ const username = hop.username.trim()
+ const password = hop.password
+ if (password && !username) {
+ throw new Error(`${label}填写密码时请同时填写账号`)
+ }
+
+ return {
+ protocol,
+ server,
+ port,
+ username: username || undefined,
+ password: password || undefined,
+ }
+ }
+
+ const localPortInput = form.localPort.trim()
+ if (localPortInput && !/^\d+$/.test(localPortInput)) {
+ throw new Error('本地监听端口必须为数字')
+ }
+ const localPort = localPortInput ? Number(localPortInput) : 0
+ if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
+ throw new Error('本地监听端口必须在 1-65535 之间')
+ }
+
+ const payload: ChainSocks5Config = {
+ first: parseHop('第一层', form.first),
+ second: parseHop('第二层', form.second),
+ localPort: localPort > 0 ? localPort : undefined,
+ }
+
+ const encodedPayload = encodeURIComponent(JSON.stringify(payload))
+ const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
+
+ return {
+ proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
+ proxyConfig,
+ }
+}
+
+function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string {
+ const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}`
+ return prefix ? `${prefix}-${rawName}` : rawName
+}
+
+export function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
+ return parsedProxies.map((proxy, index) => ({
+ proxyName: resolveImportedProxyName(proxy, index, prefix),
+ proxyConfig: proxyToYaml(proxy),
+ }))
+}
+
+export function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
+ return candidates.map((candidate, index) => {
+ const info = parseProxyInfo(candidate.proxyConfig)
+ return {
+ proxyId: `preview-${index}`,
+ proxyName: candidate.proxyName,
+ proxyConfig: candidate.proxyConfig,
+ groupName: candidate.groupName || groupName,
+ type: info.type || '-',
+ server: info.server || '-',
+ port: info.port || 0,
+ }
+ })
+}
+
+export function normalizeRefreshIntervalM(value: number): number {
+ if (!Number.isFinite(value)) return 0
+ if (value <= 0) return 0
+ if (value < 5) return 5
+ if (value > 24 * 60) return 24 * 60
+ return Math.round(value)
+}
+
+function normalizeSourceURL(sourceURL: string): string {
+ const raw = (sourceURL || '').trim()
+ if (!raw) return ''
+ try {
+ const parsed = new URL(raw)
+ parsed.hash = ''
+ return parsed.toString()
+ } catch {
+ return raw
+ }
+}
+
+function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
+ const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
+ let hash = 5381
+ for (let i = 0; i < key.length; i += 1) {
+ hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
+ }
+ const unsigned = hash >>> 0
+ return `src-${unsigned.toString(36)}`
+}
+
+export function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
+ const normalizedURL = normalizeSourceURL(sourceURL)
+ const normalizedPrefix = sourceNamePrefix.trim()
+ const existing = list.find(item =>
+ normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
+ (item.sourceNamePrefix || '').trim() === normalizedPrefix &&
+ (item.sourceId || '').trim() !== ''
+ )
+ if (existing?.sourceId?.trim()) {
+ return existing.sourceId.trim()
+ }
+ return buildStableSourceID(sourceURL, sourceNamePrefix)
+}
+
+export function nextProxyID(): string {
+ return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+export function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
+ const exactMap = new Map()
+ const nameMap = new Map()
+ oldSourceProxies.forEach(item => {
+ const exactKey = `${item.proxyName}|||${item.proxyConfig}`
+ const exactList = exactMap.get(exactKey) || []
+ exactList.push(item)
+ exactMap.set(exactKey, exactList)
+
+ const nameKey = item.proxyName
+ const nameList = nameMap.get(nameKey) || []
+ nameList.push(item)
+ nameMap.set(nameKey, nameList)
+ })
+
+ return (name: string, configText: string): string | null => {
+ const exactKey = `${name}|||${configText}`
+ const exactList = exactMap.get(exactKey)
+ if (exactList && exactList.length > 0) {
+ const item = exactList.shift()
+ if (item?.proxyId) return item.proxyId
+ }
+
+ const nameList = nameMap.get(name)
+ if (nameList && nameList.length > 0) {
+ const item = nameList.shift()
+ if (item?.proxyId) return item.proxyId
+ }
+ return null
+ }
+}
+
diff --git a/frontend/src/modules/browser/components/ProxyImportModal.tsx b/frontend/src/modules/browser/components/ProxyImportModal.tsx
index c156d08d..627a8b36 100644
--- a/frontend/src/modules/browser/components/ProxyImportModal.tsx
+++ b/frontend/src/modules/browser/components/ProxyImportModal.tsx
@@ -1,557 +1,31 @@
-import { useEffect, useMemo, useState } from 'react'
-import yaml from 'js-yaml'
-import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components'
+import { useEffect, useMemo, useState } from 'react'
+import { Button, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy } from '../types'
import { fetchClashImportFromURL, saveBrowserProxies } from '../api'
import { DIRECT_QUICK_IMPORT_TEMPLATE, buildDirectImportCandidatesFromText, parseDirectImportText } from '../pages/proxyPool/helpers'
-
-interface ProxyImportModalProps {
- open: boolean
- onClose: () => void
- existingProxies: BrowserProxy[]
- groups: string[]
- globalAutoRefreshEnabled?: boolean
- globalRefreshIntervalM?: number
- onImported?: (newProxies: BrowserProxy[]) => void | Promise
-}
-
-interface ClashProxy {
- name: string
- type: string
- server: string
- port: number
- [key: string]: any
-}
-
-type ProxyImportMode = 'clash' | 'direct' | 'chain'
-
-interface DirectImportForm {
- proxyName: string
- protocol: 'http' | 'https' | 'socks5'
- server: string
- port: string
- username: string
- password: string
-}
-
-interface ChainHopForm {
- protocol: 'http' | 'socks5'
- server: string
- port: string
- username: string
- password: string
-}
-
-interface ChainImportForm {
- proxyName: string
- localPort: string
- first: ChainHopForm
- second: ChainHopForm
-}
-
-const DIRECT_PROXY_PROTOCOL_OPTIONS = [
- { value: 'http', label: 'HTTP' },
- { value: 'https', label: 'HTTPS' },
- { value: 'socks5', label: 'SOCKS5' },
-] as const
-
-const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
- proxyName: '',
- protocol: 'http',
- server: '',
- port: '',
- username: '',
- password: '',
-}
-
-const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
- proxyName: '',
- localPort: '',
- first: {
- protocol: 'http',
- server: '',
- port: '',
- username: '',
- password: '',
- },
- second: {
- protocol: 'http',
- server: '',
- port: '',
- username: '',
- password: '',
- },
-}
-
-interface ImportCandidate {
- proxyName: string
- proxyConfig: string
- groupName?: string
-}
-
-interface ProxyDisplayInfo {
- proxyId: string
- proxyName: string
- proxyConfig: string
- groupName: string
- type: string
- server: string
- port: number
-}
-
-const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
-
-interface ChainSocks5HopConfig {
- protocol: 'http' | 'socks5'
- server: string
- port: number
- username?: string
- password?: string
-}
-
-interface ChainSocks5Config {
- localPort?: number
- first: ChainSocks5HopConfig
- second: ChainSocks5HopConfig
-}
-
-function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
- const cfg = proxyConfig.trim()
- if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
- return null
- }
- const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
- if (!encoded) {
- return null
- }
-
- const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
- if (!raw || typeof raw !== 'object') return null
- const hop = raw as Record
- const protocol = String(hop.protocol || '').trim().toLowerCase()
- if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
-
- const server = String(hop.server || '').trim()
- if (!server) return null
-
- const portVal = Number(hop.port || 0)
- if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
-
- const username = String(hop.username || '').trim()
- const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
- if (password && !username) return null
-
- return {
- protocol: protocol === 'http' ? 'http' : 'socks5',
- server,
- port: portVal,
- username: username || undefined,
- password: password || undefined,
- }
- }
-
- try {
- const decoded = decodeURIComponent(encoded)
- const parsed = JSON.parse(decoded) as Record
- const first = normalizeHop(parsed.first)
- const second = normalizeHop(parsed.second)
- if (!first || !second) return null
-
- const localPortRaw = parsed.localPort
- const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
- ? 0
- : Number(localPortRaw)
- if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
-
- return {
- first,
- second,
- localPort: localPortNum > 0 ? localPortNum : undefined,
- }
- } catch {
- return null
- }
-}
-
-function parseProxyInfo(proxyConfig: string): { type: string; server: string; port: number } {
- const cfg = proxyConfig.trim()
- if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
-
- const chain = parseChainSocks5Config(cfg)
- if (chain) {
- return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
- }
-
- const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
- if (urlMatch) {
- const scheme = urlMatch[1].toLowerCase()
- try {
- const u = new URL(cfg)
- return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 }
- } catch {
- return { type: scheme, server: '-', port: 0 }
- }
- }
- try {
- const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy
- const proxy = Array.isArray(parsed) ? parsed[0] : parsed
- return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 }
- } catch {
- return { type: '-', server: '-', port: 0 }
- }
-}
-
-function proxyToYaml(proxy: ClashProxy): string {
- return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim()
-}
-
-function quoteYamlScalar(value: string): string {
- const v = value.trim()
- if (!v) return "''"
- return `'${v.replace(/'/g, "''")}'`
-}
-
-function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null {
- const asArray = (input: unknown): ClashProxy[] => {
- if (!Array.isArray(input)) return []
- return input.filter((item): item is ClashProxy => !!item && typeof item === 'object')
- }
-
- if (Array.isArray(payload)) {
- return asArray(payload)
- }
- if (!payload || typeof payload !== 'object') {
- return null
- }
-
- const record = payload as Record
- if (Array.isArray(record.proxies)) {
- return asArray(record.proxies)
- }
- if (Array.isArray(record.proxy)) {
- return asArray(record.proxy)
- }
- if (Array.isArray(record.Proxy)) {
- return asArray(record.Proxy)
- }
- return null
-}
-
-function normalizeLooseClashImportText(raw: string): string {
- const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim()
- if (!normalizedNewline) return normalizedNewline
-
- const lines = normalizedNewline.split('\n')
- const fixedLines = lines.map(line => {
- const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i)
- if (!m) return line
- const indent = m[1] || ''
- const name = m[2] || ''
- const tail = m[3] || ''
- return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }`
- })
-
- const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line))
- if (hasProxiesRoot) {
- return fixedLines.join('\n')
- }
-
- const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line))
- if (!looksLikeProxyList) {
- return fixedLines.join('\n')
- }
-
- const indented = fixedLines.map(line => {
- if (!line.trim()) return line
- return ` ${line}`
- })
- return `proxies:\n${indented.join('\n')}`
-}
-
-function parseClashImportText(raw: string): ClashProxy[] {
- const input = raw.trim()
- if (!input) {
- throw new Error('请输入 YAML 内容')
- }
-
- const attempts = [input]
- const normalized = normalizeLooseClashImportText(input)
- if (normalized && normalized !== input) {
- attempts.push(normalized)
- }
-
- let lastError: unknown = null
- for (const text of attempts) {
- try {
- const parsed = yaml.load(text)
- const proxies = normalizeImportedProxyArray(parsed)
- if (proxies) {
- return proxies
- }
- } catch (error) {
- lastError = error
- }
- }
-
- if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) {
- throw new Error(String((lastError as { message?: string }).message || '解析失败'))
- }
- throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
-}
-
-function normalizeDirectProxyConfig(raw: string): string {
- const trimmed = raw.trim()
- if (!trimmed) return ''
- if (/^socket:\/\//i.test(trimmed)) {
- return trimmed.replace(/^socket:\/\//i, 'socks5://')
- }
- if (/^socks:\/\//i.test(trimmed)) {
- return trimmed.replace(/^socks:\/\//i, 'socks5://')
- }
- return trimmed
-}
-
-function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
- const name = rawName.trim()
- const fallbackName = server
- ? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
- : `导入代理 ${index + 1}`
- const finalName = name || fallbackName
- return prefix ? `${prefix}-${finalName}` : finalName
-}
-
-function formatDirectProxyHost(raw: string): string {
- const host = raw.trim()
- if (!host) return ''
- if (host.startsWith('[') && host.endsWith(']')) {
- return host
- }
- return host.includes(':') ? `[${host}]` : host
-}
-
-function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
- const serverInput = form.server.trim()
- if (!serverInput) {
- throw new Error('请输入代理地址')
- }
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
- throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
- }
-
- const portInput = form.port.trim()
- if (!portInput) {
- throw new Error('请输入代理端口')
- }
- if (!/^\d+$/.test(portInput)) {
- throw new Error('代理端口必须为数字')
- }
-
- const port = Number(portInput)
- if (port < 1 || port > 65535) {
- throw new Error('代理端口必须在 1-65535 之间')
- }
-
- const username = form.username.trim()
- const password = form.password
- if (password && !username) {
- throw new Error('填写密码时请同时填写账号')
- }
-
- const auth = username
- ? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
- : ''
- const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
-
- let parsedURL: URL
- try {
- parsedURL = new URL(rawConfig)
- } catch {
- throw new Error('请输入有效的代理地址')
- }
-
- if (!parsedURL.hostname) {
- throw new Error('请输入有效的代理地址')
- }
-
- const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
- const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
-
- return {
- proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
- proxyConfig: normalizedConfig,
- }
-}
-
-function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
- const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
- const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
- const server = hop.server.trim()
- if (!server) {
- throw new Error(`请输入${label}代理地址`)
- }
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
- throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
- }
-
- const portInput = hop.port.trim()
- if (!portInput) {
- throw new Error(`请输入${label}代理端口`)
- }
- if (!/^\d+$/.test(portInput)) {
- throw new Error(`${label}代理端口必须为数字`)
- }
-
- const port = Number(portInput)
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error(`${label}代理端口必须在 1-65535 之间`)
- }
-
- const username = hop.username.trim()
- const password = hop.password
- if (password && !username) {
- throw new Error(`${label}填写密码时请同时填写账号`)
- }
-
- return {
- protocol,
- server,
- port,
- username: username || undefined,
- password: password || undefined,
- }
- }
-
- const localPortInput = form.localPort.trim()
- if (localPortInput && !/^\d+$/.test(localPortInput)) {
- throw new Error('本地监听端口必须为数字')
- }
- const localPort = localPortInput ? Number(localPortInput) : 0
- if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
- throw new Error('本地监听端口必须在 1-65535 之间')
- }
-
- const payload: ChainSocks5Config = {
- first: parseHop('第一层', form.first),
- second: parseHop('第二层', form.second),
- localPort: localPort > 0 ? localPort : undefined,
- }
-
- const encodedPayload = encodeURIComponent(JSON.stringify(payload))
- const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
-
- return {
- proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
- proxyConfig,
- }
-}
-
-function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string {
- const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}`
- return prefix ? `${prefix}-${rawName}` : rawName
-}
-
-function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
- return parsedProxies.map((proxy, index) => ({
- proxyName: resolveImportedProxyName(proxy, index, prefix),
- proxyConfig: proxyToYaml(proxy),
- }))
-}
-
-function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
- return candidates.map((candidate, index) => {
- const info = parseProxyInfo(candidate.proxyConfig)
- return {
- proxyId: `preview-${index}`,
- proxyName: candidate.proxyName,
- proxyConfig: candidate.proxyConfig,
- groupName: candidate.groupName || groupName,
- type: info.type || '-',
- server: info.server || '-',
- port: info.port || 0,
- }
- })
-}
-
-function normalizeRefreshIntervalM(value: number): number {
- if (!Number.isFinite(value)) return 0
- if (value <= 0) return 0
- if (value < 5) return 5
- if (value > 24 * 60) return 24 * 60
- return Math.round(value)
-}
-
-function normalizeSourceURL(sourceURL: string): string {
- const raw = (sourceURL || '').trim()
- if (!raw) return ''
- try {
- const parsed = new URL(raw)
- parsed.hash = ''
- return parsed.toString()
- } catch {
- return raw
- }
-}
-
-function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
- const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
- let hash = 5381
- for (let i = 0; i < key.length; i += 1) {
- hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
- }
- const unsigned = hash >>> 0
- return `src-${unsigned.toString(36)}`
-}
-
-function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
- const normalizedURL = normalizeSourceURL(sourceURL)
- const normalizedPrefix = sourceNamePrefix.trim()
- const existing = list.find(item =>
- normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
- (item.sourceNamePrefix || '').trim() === normalizedPrefix &&
- (item.sourceId || '').trim() !== ''
- )
- if (existing?.sourceId?.trim()) {
- return existing.sourceId.trim()
- }
- return buildStableSourceID(sourceURL, sourceNamePrefix)
-}
-
-function nextProxyID(): string {
- return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
-}
-
-function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
- const exactMap = new Map()
- const nameMap = new Map()
- oldSourceProxies.forEach(item => {
- const exactKey = `${item.proxyName}|||${item.proxyConfig}`
- const exactList = exactMap.get(exactKey) || []
- exactList.push(item)
- exactMap.set(exactKey, exactList)
-
- const nameKey = item.proxyName
- const nameList = nameMap.get(nameKey) || []
- nameList.push(item)
- nameMap.set(nameKey, nameList)
- })
-
- return (name: string, configText: string): string | null => {
- const exactKey = `${name}|||${configText}`
- const exactList = exactMap.get(exactKey)
- if (exactList && exactList.length > 0) {
- const item = exactList.shift()
- if (item?.proxyId) return item.proxyId
- }
-
- const nameList = nameMap.get(name)
- if (nameList && nameList.length > 0) {
- const item = nameList.shift()
- if (item?.proxyId) return item.proxyId
- }
- return null
- }
-}
+import {
+ INITIAL_CHAIN_IMPORT_FORM,
+ INITIAL_DIRECT_IMPORT_FORM,
+ type ChainHopForm,
+ type ChainImportForm,
+ type DirectImportForm,
+ type ProxyDisplayInfo,
+ type ProxyImportModalProps,
+ type ProxyImportMode,
+} from './ProxyImportModal.types'
+import {
+ buildChainImportCandidate,
+ buildDirectImportCandidate,
+ buildImportCandidatesFromClash,
+ buildImportPreview,
+ createExistingProxyIDPicker,
+ parseClashImportText,
+ nextProxyID,
+ normalizeRefreshIntervalM,
+ resolveImportSourceID,
+} from './ProxyImportModal.helpers'
+import { ProxyImportModalView } from './ProxyImportModalView'
export function ProxyImportModal({
open,
@@ -797,336 +271,44 @@ export function ProxyImportModal({
], [])
return (
- <>
-
-
-
- >
- }
- >
-
-
-
-
-
-
-
- {importMode === 'clash'
- ? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)'
- : importMode === 'direct'
- ? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
- : '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
-
- {importMode === 'clash' && (
- <>
-
-
- {
- const next = e.target.value
- setImportUrl(next)
- if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
- setImportResolvedUrl('')
- }
- }}
- placeholder="订阅 URL"
- className="flex-1"
- />
-
-
- {importResolvedUrl.trim() && (
-
- 已绑定订阅:{importResolvedUrl}
-
- )}
- 获取成功后会自动回填 YAML 文本,并尝试自动填充 DNS 与建议分组
-
-
-
-
- setPreviewModalOpen(false)}
- title="确认导入以下代理"
- width="700px"
- footer={
- <>
-
-
- >
- }
- >
-
- {importMode === 'clash' && importDnsServers.trim() && (
-
已配置批量 DNS,将应用到以下所有代理
- )}
-
-
-
- >
+
)
}
diff --git a/frontend/src/modules/browser/components/ProxyImportModal.types.ts b/frontend/src/modules/browser/components/ProxyImportModal.types.ts
new file mode 100644
index 00000000..095e3a5a
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyImportModal.types.ts
@@ -0,0 +1,112 @@
+import type { BrowserProxy } from '../types'
+
+export interface ProxyImportModalProps {
+ open: boolean
+ onClose: () => void
+ existingProxies: BrowserProxy[]
+ groups: string[]
+ globalAutoRefreshEnabled?: boolean
+ globalRefreshIntervalM?: number
+ onImported?: (newProxies: BrowserProxy[]) => void | Promise
+}
+
+export interface ClashProxy {
+ name: string
+ type: string
+ server: string
+ port: number
+ [key: string]: any
+}
+
+export type ProxyImportMode = 'clash' | 'direct' | 'chain'
+
+export interface DirectImportForm {
+ proxyName: string
+ protocol: 'http' | 'https' | 'socks5'
+ server: string
+ port: string
+ username: string
+ password: string
+}
+
+export interface ChainHopForm {
+ protocol: 'http' | 'socks5'
+ server: string
+ port: string
+ username: string
+ password: string
+}
+
+export interface ChainImportForm {
+ proxyName: string
+ localPort: string
+ first: ChainHopForm
+ second: ChainHopForm
+}
+
+export const DIRECT_PROXY_PROTOCOL_OPTIONS = [
+ { value: 'http', label: 'HTTP' },
+ { value: 'https', label: 'HTTPS' },
+ { value: 'socks5', label: 'SOCKS5' },
+] as const
+
+export const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
+ proxyName: '',
+ protocol: 'http',
+ server: '',
+ port: '',
+ username: '',
+ password: '',
+}
+
+export const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
+ proxyName: '',
+ localPort: '',
+ first: {
+ protocol: 'http',
+ server: '',
+ port: '',
+ username: '',
+ password: '',
+ },
+ second: {
+ protocol: 'http',
+ server: '',
+ port: '',
+ username: '',
+ password: '',
+ },
+}
+
+export interface ImportCandidate {
+ proxyName: string
+ proxyConfig: string
+ groupName?: string
+}
+
+export interface ProxyDisplayInfo {
+ proxyId: string
+ proxyName: string
+ proxyConfig: string
+ groupName: string
+ type: string
+ server: string
+ port: number
+}
+
+export const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
+
+export interface ChainSocks5HopConfig {
+ protocol: 'http' | 'socks5'
+ server: string
+ port: number
+ username?: string
+ password?: string
+}
+
+export interface ChainSocks5Config {
+ localPort?: number
+ first: ChainSocks5HopConfig
+ second: ChainSocks5HopConfig
+}
+
diff --git a/frontend/src/modules/browser/components/ProxyImportModalView.tsx b/frontend/src/modules/browser/components/ProxyImportModalView.tsx
new file mode 100644
index 00000000..fd276563
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyImportModalView.tsx
@@ -0,0 +1,425 @@
+import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../shared/components'
+import type { TableColumn } from '../../../shared/components/Table'
+import { DIRECT_QUICK_IMPORT_TEMPLATE } from '../pages/proxyPool/helpers'
+import {
+ DIRECT_PROXY_PROTOCOL_OPTIONS,
+ type ChainHopForm,
+ type ChainImportForm,
+ type DirectImportForm,
+ type ProxyDisplayInfo,
+ type ProxyImportMode,
+} from './ProxyImportModal.types'
+
+interface ProxyImportModalViewProps {
+ open: boolean
+ onClose: () => void
+ fetchingImportUrl: boolean
+ canParseImport: boolean
+ importMode: ProxyImportMode
+ importUrl: string
+ importResolvedUrl: string
+ importText: string
+ importDnsServers: string
+ importNamePrefix: string
+ importGroupName: string
+ directImportText: string
+ directImportForm: DirectImportForm
+ chainImportForm: ChainImportForm
+ groups: string[]
+ previewModalOpen: boolean
+ previewList: ProxyDisplayInfo[]
+ importing: boolean
+ previewColumns: TableColumn[]
+ onParseImport: () => void
+ onImportModeChange: (mode: ProxyImportMode) => void
+ onImportUrlChange: (value: string) => void
+ onImportResolvedUrlChange: (value: string) => void
+ onFetchImportURL: () => Promise
+ onImportTextChange: (value: string) => void
+ onImportDnsServersChange: (value: string) => void
+ onImportNamePrefixChange: (value: string) => void
+ onImportGroupNameChange: (value: string) => void
+ onDirectImportTextChange: (value: string) => void
+ onDirectImportFormChange: import("react").Dispatch>
+ onChainImportFormChange: import("react").Dispatch>
+ onUpdateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
+ onFillDirectTemplate: () => void
+ onCopyDirectTemplate: () => Promise
+ onApplyDirectText: () => void
+ onPreviewModalOpenChange: (open: boolean) => void
+ onConfirmImport: () => Promise
+}
+
+export function ProxyImportModalView({
+ open,
+ onClose,
+ fetchingImportUrl,
+ canParseImport,
+ importMode,
+ importUrl,
+ importResolvedUrl,
+ importText,
+ importDnsServers,
+ importNamePrefix,
+ importGroupName,
+ directImportText,
+ directImportForm,
+ chainImportForm,
+ groups,
+ previewModalOpen,
+ previewList,
+ importing,
+ previewColumns,
+ onParseImport,
+ onImportModeChange,
+ onImportUrlChange,
+ onImportResolvedUrlChange,
+ onFetchImportURL,
+ onImportTextChange,
+ onImportDnsServersChange,
+ onImportNamePrefixChange,
+ onImportGroupNameChange,
+ onDirectImportTextChange,
+ onDirectImportFormChange,
+ onChainImportFormChange,
+ onUpdateChainHop,
+ onFillDirectTemplate,
+ onCopyDirectTemplate,
+ onApplyDirectText,
+ onPreviewModalOpenChange,
+ onConfirmImport,
+}: ProxyImportModalViewProps) {
+ return (
+ <>
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+ {importMode === 'clash'
+ ? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)'
+ : importMode === 'direct'
+ ? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
+ : '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
+
+ {importMode === 'clash' && (
+ <>
+
+
+ {
+ const next = e.target.value
+ onImportUrlChange(next)
+ if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
+ onImportResolvedUrlChange('')
+ }
+ }}
+ placeholder="订阅 URL"
+ className="flex-1"
+ />
+
+
+ {importResolvedUrl.trim() && (
+
+ 已绑定订阅:{importResolvedUrl}
+
+ )}
+ 获取成功后会自动回填 YAML 文本,并尝试自动填充 DNS 与建议分组
+
+
+
+
+ onPreviewModalOpenChange(false)}
+ title="确认导入以下代理"
+ width="700px"
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+ {importMode === 'clash' && importDnsServers.trim() && (
+
已配置批量 DNS,将应用到以下所有代理
+ )}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/modules/browser/components/ProxyPickerModal.edit.tsx b/frontend/src/modules/browser/components/ProxyPickerModal.edit.tsx
new file mode 100644
index 00000000..3917c1ea
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyPickerModal.edit.tsx
@@ -0,0 +1,155 @@
+import type { Dispatch, SetStateAction } from 'react'
+import { Button, FormItem, Input, Modal, Select, Textarea } from '../../../shared/components'
+import type { ChainEditForm, ChainHopForm } from './ProxyPickerModal.helpers'
+
+interface ProxyEditModalProps {
+ open: boolean
+ chainEditMode: boolean
+ editName: string
+ editConfig: string
+ editGroup: string
+ editDnsServers: string
+ chainEditForm: ChainEditForm
+ saving: boolean
+ setEditName: Dispatch>
+ setEditConfig: Dispatch>
+ setEditGroup: Dispatch>
+ setEditDnsServers: Dispatch>
+ setChainEditForm: Dispatch>
+ updateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
+ onClose: () => void
+ onSave: () => void
+}
+
+export function ProxyEditModal({
+ open,
+ chainEditMode,
+ editName,
+ editConfig,
+ editGroup,
+ editDnsServers,
+ chainEditForm,
+ saving,
+ setEditName,
+ setEditConfig,
+ setEditGroup,
+ setEditDnsServers,
+ setChainEditForm,
+ updateChainHop,
+ onClose,
+ onSave,
+}: ProxyEditModalProps) {
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ )
+}
+
+function ChainHopSection({
+ title,
+ hop,
+ form,
+ updateChainHop,
+}: {
+ title: string
+ hop: 'first' | 'second'
+ form: ChainEditForm
+ updateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
+}) {
+ const hopForm = form[hop]
+
+ return (
+
+
{title}
+
+
+
+
+ updateChainHop(hop, 'server', e.target.value)} />
+
+
+ updateChainHop(hop, 'port', e.target.value)} />
+
+
+ updateChainHop(hop, 'username', e.target.value)} />
+
+
+ updateChainHop(hop, 'password', e.target.value)} />
+
+
+
+ )
+}
diff --git a/frontend/src/modules/browser/components/ProxyPickerModal.helpers.ts b/frontend/src/modules/browser/components/ProxyPickerModal.helpers.ts
new file mode 100644
index 00000000..129f0a3c
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyPickerModal.helpers.ts
@@ -0,0 +1,201 @@
+export type SpeedResult = { ok: boolean; latencyMs: number; error: string }
+
+export type ChainSocksHop = {
+ protocol?: 'http' | 'socks5'
+ server?: string
+ port?: number
+ username?: string
+ password?: string
+}
+
+export type ChainSocksConfig = {
+ localPort?: number
+ first?: ChainSocksHop
+ second?: ChainSocksHop
+}
+
+export interface ChainHopForm {
+ protocol: 'http' | 'socks5'
+ server: string
+ port: string
+ username: string
+ password: string
+}
+
+export interface ChainEditForm {
+ proxyName: string
+ localPort: string
+ first: ChainHopForm
+ second: ChainHopForm
+}
+
+export const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
+ proxyName: '',
+ localPort: '',
+ first: { protocol: 'http', server: '', port: '', username: '', password: '' },
+ second: { protocol: 'http', server: '', port: '', username: '', password: '' },
+}
+
+export const ALL_GROUP = '__all__'
+export const DIRECT_PROXY_ID = '__direct__'
+export const SPEED_RESULT_EVENT = 'proxy:speed:result'
+export const BATCH_TEST_CONCURRENCY = 20
+export const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
+
+export function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
+ const cfg = proxyConfig.trim()
+ if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
+ return null
+ }
+ const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
+ if (!encoded) {
+ return null
+ }
+
+ const normalizeHop = (raw: unknown): ChainSocksHop | null => {
+ if (!raw || typeof raw !== 'object') return null
+ const hop = raw as Record
+ const protocol = String(hop.protocol || '').trim().toLowerCase()
+ if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
+
+ const server = String(hop.server || '').trim()
+ if (!server) return null
+
+ const portVal = Number(hop.port || 0)
+ if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
+
+ const username = String(hop.username || '').trim()
+ const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
+ if (password && !username) return null
+
+ return {
+ protocol: protocol === 'http' ? 'http' : 'socks5',
+ server,
+ port: portVal,
+ username: username || undefined,
+ password: password || undefined,
+ }
+ }
+
+ try {
+ const decoded = decodeURIComponent(encoded)
+ const parsed = JSON.parse(decoded) as Record
+ const first = normalizeHop(parsed.first)
+ const second = normalizeHop(parsed.second)
+ if (!first || !second) return null
+
+ const localPortRaw = parsed.localPort
+ const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
+ ? 0
+ : Number(localPortRaw)
+ if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
+
+ return {
+ first,
+ second,
+ localPort: localPortNum > 0 ? localPortNum : undefined,
+ }
+ } catch {
+ return null
+ }
+}
+
+export function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditForm {
+ return {
+ proxyName,
+ localPort: cfg.localPort ? String(cfg.localPort) : '',
+ first: {
+ protocol: cfg.first?.protocol || 'socks5',
+ server: cfg.first?.server || '',
+ port: cfg.first?.port ? String(cfg.first.port) : '',
+ username: cfg.first?.username || '',
+ password: cfg.first?.password || '',
+ },
+ second: {
+ protocol: cfg.second?.protocol || 'socks5',
+ server: cfg.second?.server || '',
+ port: cfg.second?.port ? String(cfg.second.port) : '',
+ username: cfg.second?.username || '',
+ password: cfg.second?.password || '',
+ },
+ }
+}
+
+export function buildChainProxyConfig(form: ChainEditForm): string {
+ const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
+ const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
+ const server = hop.server.trim()
+ if (!server) {
+ throw new Error(`请输入${label}代理地址`)
+ }
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
+ throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
+ }
+
+ const portInput = hop.port.trim()
+ if (!portInput) {
+ throw new Error(`请输入${label}代理端口`)
+ }
+ if (!/^\d+$/.test(portInput)) {
+ throw new Error(`${label}代理端口必须为数字`)
+ }
+
+ const port = Number(portInput)
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`${label}代理端口必须在 1-65535 之间`)
+ }
+
+ const username = hop.username.trim()
+ const password = hop.password
+ if (password && !username) {
+ throw new Error(`${label}填写密码时请同时填写账号`)
+ }
+
+ return {
+ protocol,
+ server,
+ port,
+ username: username || undefined,
+ password: password || undefined,
+ }
+ }
+
+ const localPortInput = form.localPort.trim()
+ if (localPortInput && !/^\d+$/.test(localPortInput)) {
+ throw new Error('本地监听端口必须为数字')
+ }
+ const localPort = localPortInput ? Number(localPortInput) : 0
+ if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
+ throw new Error('本地监听端口必须在 1-65535 之间')
+ }
+
+ const payload: ChainSocksConfig = {
+ first: parseHop('第一层', form.first),
+ second: parseHop('第二层', form.second),
+ localPort: localPort > 0 ? localPort : undefined,
+ }
+
+ const encodedPayload = encodeURIComponent(JSON.stringify(payload))
+ return `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
+}
+
+export function formatProxyConfigForDisplay(proxyConfig: string): string {
+ const raw = (proxyConfig || '').trim()
+ if (!raw || !raw.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
+ return raw
+ }
+
+ const encoded = raw.slice(CHAIN_SOCKS5_PREFIX.length)
+ if (!encoded) return raw
+
+ try {
+ const decoded = decodeURIComponent(encoded)
+ const parsed = JSON.parse(decoded) as ChainSocksConfig
+ const firstServer = (parsed.first?.server || '').trim()
+ const secondServer = (parsed.second?.server || '').trim()
+ if (!firstServer || !secondServer) return raw
+ return `${firstServer} -> ${secondServer}`
+ } catch {
+ return raw
+ }
+}
diff --git a/frontend/src/modules/browser/components/ProxyPickerModal.rows.tsx b/frontend/src/modules/browser/components/ProxyPickerModal.rows.tsx
new file mode 100644
index 00000000..338caa67
--- /dev/null
+++ b/frontend/src/modules/browser/components/ProxyPickerModal.rows.tsx
@@ -0,0 +1,89 @@
+import { Check, Loader2, Pencil, Trash2, Wifi } from 'lucide-react'
+import type { BrowserProxy } from '../types'
+import { DIRECT_PROXY_ID, type SpeedResult } from './ProxyPickerModal.helpers'
+
+export function GroupItem({ label, active, count, onClick }: { label: string; active: boolean; count: number; onClick: () => void }) {
+ return (
+
+ )
+}
+
+interface ProxyRowProps {
+ proxy: BrowserProxy
+ selected: boolean
+ testing: boolean
+ speedResult?: SpeedResult
+ displayConfig: string
+ onSelect: () => void
+ onTest: (e: React.MouseEvent) => void
+ onEdit: (e: React.MouseEvent) => void
+ onDelete: (e: React.MouseEvent) => void
+}
+
+function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
+ if (testing) return
+ if (!result) return null
+ if (!result.ok) return 失败
+ const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
+ return {result.latencyMs}ms
+}
+
+export function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
+ const isDirect = proxy.proxyId === DIRECT_PROXY_ID
+ const disableDelete = isDirect
+
+ return (
+
+
+
+ {proxy.proxyName || proxy.proxyId}
+ {proxy.groupName && [{proxy.groupName}]}
+
+
+ {displayConfig}
+
+
+
+
+
+
+ {selected &&
}
+
+ )
+}
diff --git a/frontend/src/modules/browser/components/ProxyPickerModal.tsx b/frontend/src/modules/browser/components/ProxyPickerModal.tsx
index c58c64f2..d5580403 100644
--- a/frontend/src/modules/browser/components/ProxyPickerModal.tsx
+++ b/frontend/src/modules/browser/components/ProxyPickerModal.tsx
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
-import { Check, Loader2, Pencil, Plus, Search, Trash2, Wifi, X } from 'lucide-react'
-import { Button, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
+import { Plus, Search, Wifi, X } from 'lucide-react'
+import { ConfirmModal, toast } from '../../../shared/components'
import type { BrowserProxy } from '../types'
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
import { ProxyImportModal } from './ProxyImportModal'
+import { ProxyEditModal } from './ProxyPickerModal.edit'
+import { GroupItem, ProxyRow } from './ProxyPickerModal.rows'
+import { ALL_GROUP, BATCH_TEST_CONCURRENCY, DIRECT_PROXY_ID, INITIAL_CHAIN_EDIT_FORM, SPEED_RESULT_EVENT, buildChainProxyConfig, formatProxyConfigForDisplay, parseChainSocks5Config, toChainEditForm, type ChainEditForm, type ChainHopForm, type SpeedResult } from './ProxyPickerModal.helpers'
interface ProxyPickerModalProps {
open: boolean
@@ -16,208 +19,6 @@ interface ProxyPickerModalProps {
onProxyDeleted?: (deletedProxyId: string, nextProxies: BrowserProxy[]) => void
}
-type SpeedResult = { ok: boolean; latencyMs: number; error: string }
-
-type ChainSocksHop = {
- protocol?: 'http' | 'socks5'
- server?: string
- port?: number
- username?: string
- password?: string
-}
-
-type ChainSocksConfig = {
- localPort?: number
- first?: ChainSocksHop
- second?: ChainSocksHop
-}
-
-interface ChainHopForm {
- protocol: 'http' | 'socks5'
- server: string
- port: string
- username: string
- password: string
-}
-
-interface ChainEditForm {
- proxyName: string
- localPort: string
- first: ChainHopForm
- second: ChainHopForm
-}
-
-const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
- proxyName: '',
- localPort: '',
- first: { protocol: 'http', server: '', port: '', username: '', password: '' },
- second: { protocol: 'http', server: '', port: '', username: '', password: '' },
-}
-
-function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
- const cfg = proxyConfig.trim()
- if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
- return null
- }
- const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
- if (!encoded) {
- return null
- }
-
- const normalizeHop = (raw: unknown): ChainSocksHop | null => {
- if (!raw || typeof raw !== 'object') return null
- const hop = raw as Record
- const protocol = String(hop.protocol || '').trim().toLowerCase()
- if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
-
- const server = String(hop.server || '').trim()
- if (!server) return null
-
- const portVal = Number(hop.port || 0)
- if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
-
- const username = String(hop.username || '').trim()
- const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
- if (password && !username) return null
-
- return {
- protocol: protocol === 'http' ? 'http' : 'socks5',
- server,
- port: portVal,
- username: username || undefined,
- password: password || undefined,
- }
- }
-
- try {
- const decoded = decodeURIComponent(encoded)
- const parsed = JSON.parse(decoded) as Record
- const first = normalizeHop(parsed.first)
- const second = normalizeHop(parsed.second)
- if (!first || !second) return null
-
- const localPortRaw = parsed.localPort
- const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
- ? 0
- : Number(localPortRaw)
- if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
-
- return {
- first,
- second,
- localPort: localPortNum > 0 ? localPortNum : undefined,
- }
- } catch {
- return null
- }
-}
-
-function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditForm {
- return {
- proxyName,
- localPort: cfg.localPort ? String(cfg.localPort) : '',
- first: {
- protocol: cfg.first?.protocol || 'socks5',
- server: cfg.first?.server || '',
- port: cfg.first?.port ? String(cfg.first.port) : '',
- username: cfg.first?.username || '',
- password: cfg.first?.password || '',
- },
- second: {
- protocol: cfg.second?.protocol || 'socks5',
- server: cfg.second?.server || '',
- port: cfg.second?.port ? String(cfg.second.port) : '',
- username: cfg.second?.username || '',
- password: cfg.second?.password || '',
- },
- }
-}
-
-function buildChainProxyConfig(form: ChainEditForm): string {
- const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
- const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
- const server = hop.server.trim()
- if (!server) {
- throw new Error(`请输入${label}代理地址`)
- }
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
- throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
- }
-
- const portInput = hop.port.trim()
- if (!portInput) {
- throw new Error(`请输入${label}代理端口`)
- }
- if (!/^\d+$/.test(portInput)) {
- throw new Error(`${label}代理端口必须为数字`)
- }
-
- const port = Number(portInput)
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error(`${label}代理端口必须在 1-65535 之间`)
- }
-
- const username = hop.username.trim()
- const password = hop.password
- if (password && !username) {
- throw new Error(`${label}填写密码时请同时填写账号`)
- }
-
- return {
- protocol,
- server,
- port,
- username: username || undefined,
- password: password || undefined,
- }
- }
-
- const localPortInput = form.localPort.trim()
- if (localPortInput && !/^\d+$/.test(localPortInput)) {
- throw new Error('本地监听端口必须为数字')
- }
- const localPort = localPortInput ? Number(localPortInput) : 0
- if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
- throw new Error('本地监听端口必须在 1-65535 之间')
- }
-
- const payload: ChainSocksConfig = {
- first: parseHop('第一层', form.first),
- second: parseHop('第二层', form.second),
- localPort: localPort > 0 ? localPort : undefined,
- }
-
- const encodedPayload = encodeURIComponent(JSON.stringify(payload))
- return `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
-}
-const ALL_GROUP = '__all__'
-const DIRECT_PROXY_ID = '__direct__'
-const SPEED_RESULT_EVENT = 'proxy:speed:result'
-const BATCH_TEST_CONCURRENCY = 20
-const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
-
-function formatProxyConfigForDisplay(proxyConfig: string): string {
- const raw = (proxyConfig || '').trim()
- if (!raw || !raw.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
- return raw
- }
-
- const encoded = raw.slice(CHAIN_SOCKS5_PREFIX.length)
- if (!encoded) return raw
-
- try {
- const decoded = decodeURIComponent(encoded)
- const parsed = JSON.parse(decoded) as ChainSocksConfig
- const firstServer = (parsed.first?.server || '').trim()
- const secondServer = (parsed.second?.server || '').trim()
- if (!firstServer || !secondServer) return raw
- return `${firstServer} -> ${secondServer}`
- } catch {
- return raw
- }
-}
-
-
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onProxyListUpdated, onProxyDeleted }: ProxyPickerModalProps) {
const [groups, setGroups] = useState([])
const [allProxies, setAllProxies] = useState([])
@@ -618,128 +419,24 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
onImported={handleImported}
/>
-
-
-
- >
- }
- >
-
-
- {
- if (chainEditMode) {
- setChainEditForm(prev => ({ ...prev, proxyName: e.target.value }))
- } else {
- setEditName(e.target.value)
- }
- }}
- placeholder="节点名称"
- />
-
-
-
- setEditGroup(e.target.value)} placeholder="分组名称" />
-
-
- {chainEditMode ? (
-
-
- setChainEditForm(prev => ({ ...prev, localPort: e.target.value }))}
- placeholder="留空自动分配"
- />
-
-
-
-
第一层代理
-
-
-
-
- updateChainHop('first', 'server', e.target.value)} />
-
-
- updateChainHop('first', 'port', e.target.value)} />
-
-
- updateChainHop('first', 'username', e.target.value)} />
-
-
- updateChainHop('first', 'password', e.target.value)} />
-
-
-
-
-
-
第二层代理
-
-
-
-
- updateChainHop('second', 'server', e.target.value)} />
-
-
- updateChainHop('second', 'port', e.target.value)} />
-
-
- updateChainHop('second', 'username', e.target.value)} />
-
-
- updateChainHop('second', 'password', e.target.value)} />
-
-
-
-
- ) : (
-
- setEditConfig(e.target.value)}
- rows={6}
- placeholder="支持 http://、https://、socks5://、chain+socks5://"
- />
-
- )}
-
-
- setEditDnsServers(e.target.value)}
- rows={4}
- placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`}
- />
-
-
-
-
+ onSave={handleSaveEdit}
+ />
setDeleteCandidate(null)}
@@ -755,88 +452,3 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
)
}
-function GroupItem({ label, active, count, onClick }: { label: string; active: boolean; count: number; onClick: () => void }) {
- return (
-
- )
-}
-
-interface ProxyRowProps {
- proxy: BrowserProxy
- selected: boolean
- testing: boolean
- speedResult?: SpeedResult
- displayConfig: string
- onSelect: () => void
- onTest: (e: React.MouseEvent) => void
- onEdit: (e: React.MouseEvent) => void
- onDelete: (e: React.MouseEvent) => void
-}
-
-function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
- if (testing) return
- if (!result) return null
- if (!result.ok) return 失败
- const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
- return {result.latencyMs}ms
-}
-
-function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
- const isDirect = proxy.proxyId === DIRECT_PROXY_ID
- const disableDelete = isDirect
-
- return (
-
-
-
- {proxy.proxyName || proxy.proxyId}
- {proxy.groupName && [{proxy.groupName}]}
-
-
- {displayConfig}
-
-
-
-
-
-
- {selected &&
}
-
- )
-}
diff --git a/frontend/src/modules/browser/components/QuickLaunchModal.helpers.ts b/frontend/src/modules/browser/components/QuickLaunchModal.helpers.ts
new file mode 100644
index 00000000..38cdbd9b
--- /dev/null
+++ b/frontend/src/modules/browser/components/QuickLaunchModal.helpers.ts
@@ -0,0 +1,47 @@
+import type { BrowserProfile } from '../types'
+
+export interface ProfileTagSection {
+ tag: string
+ items: BrowserProfile[]
+}
+
+export interface GroupFilterOption {
+ id: string
+ name: string
+ count: number
+}
+
+export const UNTAGGED_LABEL = '未打标签'
+export const GROUP_ALL = '__all__'
+export const GROUP_UNGROUPED = '__ungrouped__'
+
+export function normalizeText(v?: string): string {
+ return (v || '').trim().toLowerCase()
+}
+
+export function normalizeCode(v?: string): string {
+ return normalizeText(v).toUpperCase()
+}
+
+export function buildSearchText(profile: BrowserProfile): string {
+ return [
+ profile.profileName,
+ profile.launchCode || '',
+ ...(profile.tags || []),
+ ...(profile.keywords || []),
+ ]
+ .join(' ')
+ .toLowerCase()
+}
+
+export function sortProfiles(a: BrowserProfile, b: BrowserProfile): number {
+ if (a.running !== b.running) {
+ return a.running ? -1 : 1
+ }
+ return a.profileName.localeCompare(b.profileName, 'zh-CN')
+}
+
+export function pickPrimaryTag(profile: BrowserProfile): string {
+ const tags = (profile.tags || []).map(t => t.trim()).filter(Boolean)
+ return tags.length > 0 ? tags[0] : UNTAGGED_LABEL
+}
diff --git a/frontend/src/modules/browser/components/QuickLaunchModal.tsx b/frontend/src/modules/browser/components/QuickLaunchModal.tsx
index 2b63820e..e37c2095 100644
--- a/frontend/src/modules/browser/components/QuickLaunchModal.tsx
+++ b/frontend/src/modules/browser/components/QuickLaunchModal.tsx
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Keyboard, Play, Search, Tag } from 'lucide-react'
import { Badge, Button, Modal, toast } from '../../../shared/components'
-import { fetchBrowserProfiles, fetchGroups, startBrowserInstanceByCode } from '../api'
-import type { BrowserGroupWithCount, BrowserProfile } from '../types'
+import { startBrowserInstanceByCode } from '../api'
+import type { BrowserProfile } from '../types'
import { resolveActionFeedback } from '../utils/actionErrors'
interface QuickLaunchModalProps {
@@ -10,56 +10,9 @@ interface QuickLaunchModalProps {
onClose: () => void
}
-interface ProfileTagSection {
- tag: string
- items: BrowserProfile[]
-}
-
-interface GroupFilterOption {
- id: string
- name: string
- count: number
-}
-
-const UNTAGGED_LABEL = '未打标签'
-const GROUP_ALL = '__all__'
-const GROUP_UNGROUPED = '__ungrouped__'
-
-function normalizeText(v?: string): string {
- return (v || '').trim().toLowerCase()
-}
-
-function normalizeCode(v?: string): string {
- return normalizeText(v).toUpperCase()
-}
-
-function buildSearchText(profile: BrowserProfile): string {
- return [
- profile.profileName,
- profile.launchCode || '',
- ...(profile.tags || []),
- ...(profile.keywords || []),
- ]
- .join(' ')
- .toLowerCase()
-}
-
-function sortProfiles(a: BrowserProfile, b: BrowserProfile): number {
- if (a.running !== b.running) {
- return a.running ? -1 : 1
- }
- return a.profileName.localeCompare(b.profileName, 'zh-CN')
-}
-
-function pickPrimaryTag(profile: BrowserProfile): string {
- const tags = (profile.tags || []).map(t => t.trim()).filter(Boolean)
- return tags.length > 0 ? tags[0] : UNTAGGED_LABEL
-}
-
+import { buildSearchText, GROUP_ALL, GROUP_UNGROUPED, normalizeCode, normalizeText, pickPrimaryTag, UNTAGGED_LABEL, type GroupFilterOption, type ProfileTagSection } from './QuickLaunchModal.helpers'
+import { useQuickLaunchData } from './useQuickLaunchData'
export function QuickLaunchModal({ open, onClose }: QuickLaunchModalProps) {
- const [profiles, setProfiles] = useState([])
- const [groups, setGroups] = useState([])
- const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [groupFilter, setGroupFilter] = useState(GROUP_ALL)
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -72,45 +25,19 @@ export function QuickLaunchModal({ open, onClose }: QuickLaunchModalProps) {
const autoScrollingRef = useRef(false)
const autoScrollTimerRef = useRef(null)
+
+ const { profiles, groups, loading } = useQuickLaunchData(open, inputRef)
+
useEffect(() => {
if (!open) return
-
- let alive = true
setQuery('')
setGroupFilter(GROUP_ALL)
setSelectedIndex(0)
- setLoading(true)
-
- Promise.allSettled([fetchBrowserProfiles(), fetchGroups()])
- .then(([profilesResult, groupsResult]) => {
- if (!alive) return
-
- if (profilesResult.status === 'fulfilled') {
- setProfiles((profilesResult.value || []).slice().sort(sortProfiles))
- } else {
- toast.error('加载实例列表失败')
- setProfiles([])
- }
-
- if (groupsResult.status === 'fulfilled') {
- setGroups(groupsResult.value || [])
- } else {
- setGroups([])
- }
- })
- .finally(() => {
- if (!alive) return
- setLoading(false)
- setTimeout(() => inputRef.current?.focus(), 0)
- })
-
return () => {
- alive = false
setStartingCode('')
setActiveTag('')
}
}, [open])
-
const groupNameMap = useMemo(() => {
const map = new Map()
groups.forEach((group) => {
@@ -560,3 +487,4 @@ export function QuickLaunchModal({ open, onClose }: QuickLaunchModalProps) {
)
}
+
diff --git a/frontend/src/modules/browser/components/useAutomationScriptRunProfiles.ts b/frontend/src/modules/browser/components/useAutomationScriptRunProfiles.ts
new file mode 100644
index 00000000..2c82c9e4
--- /dev/null
+++ b/frontend/src/modules/browser/components/useAutomationScriptRunProfiles.ts
@@ -0,0 +1,312 @@
+import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
+import { toast } from "../../../shared/components";
+import { fetchBrowserProfiles, fetchGroups } from "../api";
+import type { AutomationScriptRecord } from "../automationScripts";
+import type { AutomationDemoSession } from "../demoSession";
+import type { BrowserGroupWithCount, BrowserProfile } from "../types";
+import {
+ buildGroupOptions,
+ buildProfileSuggestions,
+} from "../pages/automationScriptDetail/helpers";
+import type { DemoCreateDraft, DemoPreparationMode, SelectableProfile } from "./AutomationScriptRunModal.types";
+import { DEFAULT_DEMO_CREATE_DRAFT } from "./AutomationScriptRunModal.types";
+import {
+ buildDemoSelectorText,
+ filterSelectableProfiles,
+ isCodeOnlySelectorForLaunchCode,
+ isPlaceholderSelectorText,
+ normalizeLaunchCode,
+ resolveInitialSelectorText,
+ resolvePreferredProfileId,
+ resolveSelectorLaunchCode,
+ sortTemplateProfiles,
+} from "./AutomationScriptRunModal.helpers";
+
+interface UseAutomationScriptRunProfilesOptions {
+ open: boolean;
+ script: AutomationScriptRecord | null;
+ isManualTargetMode: boolean;
+ usesStoredTargetConfig: boolean;
+ selectorText: string;
+ setSelectorText: (value: string) => void;
+ demoSession: AutomationDemoSession;
+ setDemoSession: Dispatch>;
+ reloadDemoSession: () => AutomationDemoSession;
+}
+
+export function useAutomationScriptRunProfiles({
+ open,
+ script,
+ isManualTargetMode,
+ usesStoredTargetConfig,
+ selectorText,
+ setSelectorText,
+ demoSession,
+ setDemoSession,
+ reloadDemoSession,
+}: UseAutomationScriptRunProfilesOptions) {
+ const [demoMode, setDemoMode] = useState("select");
+ const [availableProfiles, setAvailableProfiles] = useState(
+ [],
+ );
+ const [templateProfiles, setTemplateProfiles] = useState([]);
+ const [allProfiles, setAllProfiles] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [profilesLoading, setProfilesLoading] = useState(false);
+ const [selectedProfileId, setSelectedProfileId] = useState("");
+ const [createDraft, setCreateDraft] = useState(
+ DEFAULT_DEMO_CREATE_DRAFT,
+ );
+ const selectedProfile =
+ availableProfiles.find((profile) => profile.profileId === selectedProfileId) ||
+ null;
+ const selectorDetachedFromSelectedProfile =
+ demoMode === "select" &&
+ !!selectedProfile &&
+ !!selectorText.trim() &&
+ !isPlaceholderSelectorText(selectorText) &&
+ !isCodeOnlySelectorForLaunchCode(selectorText, selectedProfile.launchCode);
+ const selectedLaunchCode = resolveSelectorLaunchCode(selectorText);
+ const codeSuggestions = buildProfileSuggestions(
+ allProfiles,
+ (profile) => profile.launchCode,
+ (profile) =>
+ profile.profileName
+ ? `${profile.launchCode || "未设 Code"} · ${profile.profileName}`
+ : profile.profileId,
+ );
+ const profileIdSuggestions = buildProfileSuggestions(
+ allProfiles,
+ (profile) => profile.profileId,
+ (profile) =>
+ profile.launchCode
+ ? `${profile.launchCode} · ${profile.profileName || profile.profileId}`
+ : profile.profileName || profile.profileId,
+ );
+ const profileNameSuggestions = buildProfileSuggestions(
+ allProfiles,
+ (profile) => profile.profileName,
+ (profile) =>
+ profile.launchCode
+ ? `${profile.launchCode} · ${profile.profileId}`
+ : profile.profileId,
+ );
+ const groupOptions = [{ value: "", label: "不限制" }, ...buildGroupOptions(groups)];
+ const syncDemoSessionFromProfile = (
+ profile: SelectableProfile,
+ actionLabel: string,
+ ) => {
+ setDemoSession((current) => ({
+ ...current,
+ profileId: profile.profileId,
+ profileName: profile.profileName,
+ launchCode: profile.launchCode,
+ cdpUrl:
+ profile.running && profile.debugReady && profile.debugPort > 0
+ ? `http://127.0.0.1:${profile.debugPort}`
+ : "",
+ debugPort:
+ profile.running && profile.debugReady && profile.debugPort > 0
+ ? profile.debugPort
+ : 0,
+ lastAction: actionLabel,
+ }));
+ };
+
+ const refreshSelectableProfiles = async (
+ preferredProfileId = "",
+ preferredLaunchCode = "",
+ showError = false,
+ ) => {
+ setProfilesLoading(true);
+ try {
+ const allProfiles = await fetchBrowserProfiles();
+ setAllProfiles(allProfiles);
+ const profiles = filterSelectableProfiles(allProfiles);
+ const nextSelectedProfileId =
+ resolvePreferredProfileId(
+ profiles,
+ preferredProfileId,
+ preferredLaunchCode,
+ ) ||
+ (selectedProfileId &&
+ profiles.some((profile) => profile.profileId === selectedProfileId)
+ ? selectedProfileId
+ : isManualTargetMode
+ ? ""
+ : profiles[0]?.profileId || "");
+ const nextSelectedProfile =
+ profiles.find((profile) => profile.profileId === nextSelectedProfileId) ||
+ null;
+
+ setAvailableProfiles(profiles);
+ setTemplateProfiles(sortTemplateProfiles(allProfiles));
+ setSelectedProfileId(nextSelectedProfileId);
+ if (demoMode === "select" && nextSelectedProfile) {
+ const keepManualSelector =
+ !!selectorText.trim() &&
+ !isPlaceholderSelectorText(selectorText) &&
+ !isCodeOnlySelectorForLaunchCode(
+ selectorText,
+ nextSelectedProfile.launchCode,
+ );
+ const nextSelectorText = buildDemoSelectorText(
+ nextSelectedProfile.launchCode,
+ );
+ if (
+ !keepManualSelector &&
+ resolveSelectorLaunchCode(selectorText) !==
+ nextSelectedProfile.launchCode
+ ) {
+ setSelectorText(nextSelectorText);
+ }
+ if (!keepManualSelector) {
+ syncDemoSessionFromProfile(nextSelectedProfile, "选择实例");
+ }
+ }
+ setCreateDraft((current) => {
+ if (
+ current.templateProfileId &&
+ allProfiles.some((profile) => profile.profileId === current.templateProfileId)
+ ) {
+ return current;
+ }
+ return {
+ ...current,
+ templateProfileId: allProfiles[0]?.profileId || "",
+ };
+ });
+ if (!profiles.length && !isManualTargetMode) {
+ setDemoMode("create");
+ }
+ } catch (error: unknown) {
+ if (showError) {
+ const message =
+ error instanceof Error ? error.message : "实例列表刷新失败";
+ toast.error(message);
+ }
+ } finally {
+ setProfilesLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ let disposed = false;
+ void fetchGroups().then(
+ (items) => {
+ if (!disposed) {
+ setGroups(items || []);
+ }
+ },
+ () => {
+ if (!disposed) {
+ setGroups([]);
+ }
+ },
+ );
+ return () => {
+ disposed = true;
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !script) {
+ setAvailableProfiles([]);
+ setSelectedProfileId("");
+ return;
+ }
+
+ const nextDemoSession = reloadDemoSession();
+ const nextSelectorText = resolveInitialSelectorText(script, nextDemoSession);
+ void refreshSelectableProfiles(
+ script.targetConfig.selector.profileId || nextDemoSession.profileId,
+ resolveSelectorLaunchCode(nextSelectorText) || nextDemoSession.launchCode,
+ false,
+ );
+ }, [open, script, usesStoredTargetConfig]);
+
+ useEffect(() => {
+ if (!open || !script || script.type !== "playwright-cdp") {
+ return;
+ }
+ if (usesStoredTargetConfig) {
+ return;
+ }
+ if (demoMode !== "select") {
+ return;
+ }
+
+ void refreshSelectableProfiles("", demoSession.launchCode, false);
+ }, [demoMode, demoSession.launchCode, open, script, usesStoredTargetConfig]);
+
+ const handleSelectedProfileChange = (profileId: string) => {
+ setSelectedProfileId(profileId);
+ const profile =
+ availableProfiles.find((item) => item.profileId === profileId) || null;
+ if (!profile) {
+ return;
+ }
+
+ setSelectorText(buildDemoSelectorText(profile.launchCode));
+ syncDemoSessionFromProfile(profile, "选择实例");
+ };
+
+ const handleLaunchCodeChange = (code: string) => {
+ const launchCode = normalizeLaunchCode(code);
+ setSelectorText(launchCode ? buildDemoSelectorText(launchCode) : "");
+ const profile =
+ availableProfiles.find((item) => item.launchCode === launchCode) || null;
+ setSelectedProfileId(profile?.profileId || "");
+ if (profile) {
+ syncDemoSessionFromProfile(profile, "填写实例 Code");
+ }
+ };
+
+ const handleSelectorTextChange = (value: string) => {
+ setSelectorText(value);
+ const launchCode = resolveSelectorLaunchCode(value);
+ const profile =
+ availableProfiles.find((item) => item.launchCode === launchCode) || null;
+ setSelectedProfileId(profile?.profileId || "");
+ if (profile) {
+ syncDemoSessionFromProfile(profile, "填写 selector");
+ }
+ };
+
+ const handleRestoreSelectedProfileSelector = () => {
+ if (!selectedProfile) {
+ return;
+ }
+
+ setSelectorText(buildDemoSelectorText(selectedProfile.launchCode));
+ syncDemoSessionFromProfile(selectedProfile, "选择实例");
+ };
+
+
+ return {
+ demoMode,
+ setDemoMode,
+ availableProfiles,
+ templateProfiles,
+ profilesLoading,
+ selectedProfileId,
+ setSelectedProfileId,
+ createDraft,
+ setCreateDraft,
+ selectedProfile,
+ selectorDetachedFromSelectedProfile,
+ selectedLaunchCode,
+ codeSuggestions,
+ profileIdSuggestions,
+ profileNameSuggestions,
+ groupOptions,
+ syncDemoSessionFromProfile,
+ handleSelectedProfileChange,
+ handleLaunchCodeChange,
+ handleSelectorTextChange,
+ handleRestoreSelectedProfileSelector,
+ };
+}
diff --git a/frontend/src/modules/browser/components/useQuickLaunchData.ts b/frontend/src/modules/browser/components/useQuickLaunchData.ts
new file mode 100644
index 00000000..424c0ded
--- /dev/null
+++ b/frontend/src/modules/browser/components/useQuickLaunchData.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState, type RefObject } from 'react'
+import { toast } from '../../../shared/components'
+import { fetchBrowserProfiles, fetchGroups } from '../api'
+import type { BrowserGroupWithCount, BrowserProfile } from '../types'
+import { sortProfiles } from './QuickLaunchModal.helpers'
+
+export function useQuickLaunchData(open: boolean, inputRef: RefObject) {
+ const [profiles, setProfiles] = useState([])
+ const [groups, setGroups] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!open) return
+
+ let alive = true
+ setLoading(true)
+
+ Promise.allSettled([fetchBrowserProfiles(), fetchGroups()])
+ .then(([profilesResult, groupsResult]) => {
+ if (!alive) return
+
+ if (profilesResult.status === 'fulfilled') {
+ setProfiles((profilesResult.value || []).slice().sort(sortProfiles))
+ } else {
+ toast.error('加载实例列表失败')
+ setProfiles([])
+ }
+
+ setGroups(groupsResult.status === 'fulfilled' ? groupsResult.value || [] : [])
+ })
+ .finally(() => {
+ if (!alive) return
+ setLoading(false)
+ setTimeout(() => inputRef.current?.focus(), 0)
+ })
+
+ return () => {
+ alive = false
+ }
+ }, [inputRef, open])
+
+ return { profiles, groups, loading }
+}
diff --git a/frontend/src/modules/browser/pages/AutomationCardsSection.tsx b/frontend/src/modules/browser/pages/AutomationCardsSection.tsx
new file mode 100644
index 00000000..a7b8ef8d
--- /dev/null
+++ b/frontend/src/modules/browser/pages/AutomationCardsSection.tsx
@@ -0,0 +1,100 @@
+import { PlusSquare, Upload } from "lucide-react";
+import { Button } from "../../../shared/components";
+import { resolveAutomationScriptPublicAPIConfig, type AutomationScriptRecord } from "../automationScripts";
+import { AutomationScriptSummaryCard } from "./AutomationScriptSummaryCard";
+import type { AutomationCardPresentation } from "./AutomationPage.helpers";
+
+interface AutomationCardsSectionProps {
+ loading: boolean;
+ cards: AutomationCardPresentation[];
+ scripts: AutomationScriptRecord[];
+ onCreate: () => void;
+ onImport: () => void;
+ onOpenScript: (scriptId: string) => void;
+ onRunAutomationScript: (script: AutomationScriptRecord) => void;
+ onOpenPublicApi: (script: AutomationScriptRecord, options?: { focusTest?: boolean }) => void;
+}
+
+export function AutomationCardsSection({
+ loading,
+ cards,
+ scripts,
+ onCreate,
+ onImport,
+ onOpenScript,
+ onRunAutomationScript,
+ onOpenPublicApi,
+}: AutomationCardsSectionProps) {
+ const scriptMap = new Map(scripts.map((script) => [script.id, script]));
+
+ return (
+
+ {loading ? (
+
+ 正在加载脚本列表...
+
+ ) : cards.length === 0 ? (
+
+
+ 还没有脚本
+
+
+ 先新建一套脚本,或者导入已有脚本。
+
+
+
+
+
+
+ ) : (
+
+ {cards.map((card) => {
+ const scriptId = card.scriptId;
+ const onOpen = scriptId ? () => onOpenScript(scriptId) : undefined;
+ const script = scriptId ? scriptMap.get(scriptId) : undefined;
+ const publicAPIEnabled = script
+ ? resolveAutomationScriptPublicAPIConfig(script).enabled
+ : false;
+ const runScriptAction = script && script.type !== "launch-api"
+ ? () => onRunAutomationScript(script)
+ : undefined;
+ const onRunAPI = script
+ ? () =>
+ onOpenPublicApi(script, {
+ focusTest: publicAPIEnabled,
+ })
+ : undefined;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+
+}
diff --git a/frontend/src/modules/browser/pages/AutomationPage.helpers.ts b/frontend/src/modules/browser/pages/AutomationPage.helpers.ts
new file mode 100644
index 00000000..b8309650
--- /dev/null
+++ b/frontend/src/modules/browser/pages/AutomationPage.helpers.ts
@@ -0,0 +1,461 @@
+import { toast } from "../../../shared/components";
+import {
+ findAutomationTargetProfile,
+ prepareAutomationScriptPublicAPIConfigForSave,
+ resolveAutomationScriptPublicAPIConfig,
+ type AutomationScriptPublicAPIConfig,
+ type AutomationScriptRecord,
+ type AutomationScriptType,
+} from "../automationScripts";
+import type { BrowserProfile } from "../types";
+
+export type ImportMode =
+ | "text"
+ | "local-file"
+ | "local-dir"
+ | "local-library"
+ | "remote-url"
+ | "git";
+export const DUAL_INSTANCE_SCRIPT_ID = "dual-instance-runtime-switch";
+export const NEWS_SCRIPT_ID = "news-query-txt";
+
+export type DualLaunchCodes = {
+ primaryCode: string;
+ secondaryCode: string;
+};
+
+export type AutomationCardPresentation = {
+ key: string;
+ title: string;
+ scriptId?: string;
+ scriptType: AutomationScriptType;
+ modeLabel: string;
+ description: string;
+ codeDisplay: string;
+ primaryActionLabel: string;
+ primaryActionText: string;
+ primaryActionSuccessMessage: string;
+ secondaryActionLabel: string;
+ secondaryActionText: string;
+ secondaryActionSuccessMessage: string;
+ modeToneClass: string;
+ publicAPIEnabled: boolean;
+ railClassName: string;
+};
+
+function getAutomationCardRailClass(seed: string): string {
+ const palette = [
+ "bg-[#8aa0b3]",
+ "bg-[#8da79b]",
+ "bg-[#929ab1]",
+ "bg-[#aa9a8e]",
+ "bg-[#8b9a9c]",
+ ];
+
+ let hash = 0;
+ for (const char of seed) {
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
+ }
+
+ return palette[hash % palette.length];
+}
+
+function normalizeText(value?: string): string {
+ return String(value || "").trim();
+}
+
+function normalizeCode(value?: string): string {
+ return normalizeText(value).toUpperCase();
+}
+
+function resolveTargetCode(
+ selector: AutomationScriptRecord["targetConfig"]["selector"],
+ profiles: BrowserProfile[],
+): string {
+ const matched = findAutomationTargetProfile(selector, profiles);
+ return normalizeCode(matched?.launchCode || selector.code);
+}
+
+export async function copyToClipboard(text: string, successMessage: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(successMessage);
+ } catch {
+ toast.error("复制失败");
+ }
+}
+
+function parseJSONObjectText(text?: string): Record | null {
+ const normalized = normalizeText(text);
+ if (!normalized) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+}
+
+function buildSelectorPayload(
+ selector: AutomationScriptRecord["targetConfig"]["selector"],
+ profiles: BrowserProfile[],
+): Record | null {
+ const matched = findAutomationTargetProfile(selector, profiles);
+ const payload: Record = {};
+
+ const code = normalizeCode(matched?.launchCode || selector.code);
+ const profileId = normalizeText(matched?.profileId || selector.profileId);
+ const profileName = normalizeText(
+ matched?.profileName || selector.profileName,
+ );
+ const groupId = normalizeText(selector.groupId);
+
+ if (code) {
+ payload.code = code;
+ }
+ if (profileId) {
+ payload.profileId = profileId;
+ }
+ if (profileName) {
+ payload.profileName = profileName;
+ }
+ if (groupId) {
+ payload.groupId = groupId;
+ }
+ if (selector.keywords.length > 0) {
+ payload.keywords = [...selector.keywords];
+ }
+ if (selector.tags.length > 0) {
+ payload.tags = [...selector.tags];
+ }
+
+ return Object.keys(payload).length > 0 ? payload : null;
+}
+
+function buildAutomationRequestPayload(
+ script: AutomationScriptRecord,
+ profiles: BrowserProfile[],
+): Record {
+ const payload: Record = {
+ scriptId: script.id,
+ };
+ const params = parseJSONObjectText(script.paramsText);
+
+ switch (script.targetConfig.mode) {
+ case "existing":
+ case "rotate": {
+ const selector = buildSelectorPayload(script.targetConfig.selector, profiles);
+ if (selector) {
+ payload.selector = selector;
+ }
+ break;
+ }
+ case "create":
+ payload.useScriptSelector = true;
+ break;
+ default: {
+ const selector = parseJSONObjectText(script.selectorText);
+ if (selector && Object.keys(selector).length > 0) {
+ payload.selector = selector;
+ } else if (script.type === "playwright-cdp") {
+ payload.selector = { code: "YOUR_CODE" };
+ }
+ break;
+ }
+ }
+
+ if (params && Object.keys(params).length > 0) {
+ payload.params = params;
+ } else {
+ payload.useScriptParams = true;
+ }
+
+ return payload;
+}
+
+export function buildAutomationRequestPayloadText(
+ payload: Record,
+): string {
+ return JSON.stringify(payload, null, 2);
+}
+
+export function buildAutomationRunCurlDemo(options: {
+ launchBaseUrl: string;
+ apiAuthEnabled: boolean;
+ apiAuthHeader: string;
+ payload: Record;
+}): string {
+ const authHeader = buildCurlAuthHeaderLine(
+ options.apiAuthEnabled,
+ options.apiAuthHeader,
+ );
+ return `curl -X POST ${options.launchBaseUrl}/api/automation/scripts/run \\
+ -H "Content-Type: application/json" \\
+${authHeader} -d '${buildAutomationRequestPayloadText(options.payload)}'`;
+}
+
+function buildAutomationCardMode(
+ script: AutomationScriptRecord,
+): "skill" | "api-sim" {
+ return script.type === "playwright-cdp" ? "skill" : "api-sim";
+}
+
+function getAutomationModeLabel(type: AutomationScriptType): string {
+ return type === "playwright-cdp" ? "脚本模式" : "接口模式";
+}
+
+function getAutomationModeToneClass(type: AutomationScriptType): string {
+ return type === "playwright-cdp"
+ ? "bg-[var(--color-text-primary)]"
+ : "bg-[var(--color-text-secondary)]";
+}
+
+function buildAutomationSkillPrompt(
+ script: AutomationScriptRecord,
+ payload: Record,
+) {
+ const lines = [
+ "使用 ant-chrome-openclaw skill。",
+ `执行预置脚本 ${script.id}(${script.name})。`,
+ ];
+
+ if (Object.prototype.hasOwnProperty.call(payload, "selector")) {
+ lines.push(`selector: ${JSON.stringify(payload.selector)}`);
+ } else if (payload.useScriptSelector) {
+ lines.push("selector: 使用脚本默认值。");
+ }
+
+ if (Object.prototype.hasOwnProperty.call(payload, "params")) {
+ lines.push(`params: ${JSON.stringify(payload.params)}`);
+ } else if (payload.useScriptParams) {
+ lines.push("params: 使用脚本默认值。");
+ }
+
+ return lines.join("\n");
+}
+
+function buildAutomationShortDescription(
+ script: AutomationScriptRecord,
+): string {
+ switch (script.id) {
+ case DUAL_INSTANCE_SCRIPT_ID:
+ return "启动双实例并切换 Runtime";
+ case NEWS_SCRIPT_ID:
+ return "搜索新闻并写入 TXT";
+ default:
+ break;
+ }
+
+ const source = normalizeText(script.description || script.name);
+ const firstSentence = source.split(/[。!?\n]/)[0]?.trim() || "按预置流程执行自动化";
+ const compact = firstSentence
+ .replace(/^通过/, "")
+ .replace(/^使用/, "")
+ .replace(/^基于/, "")
+ .replace(/浏览器实例/g, "实例")
+ .replace(/本地 txt/gi, "TXT")
+ .replace(/\s+/g, " ");
+
+ return compact.length > 30 ? `${compact.slice(0, 28).trim()}...` : compact;
+}
+
+function buildAutomationCodeDisplay(
+ script: AutomationScriptRecord,
+ profiles: BrowserProfile[],
+ dualLaunchCodes: DualLaunchCodes,
+): string {
+ if (script.id === DUAL_INSTANCE_SCRIPT_ID) {
+ return `${dualLaunchCodes.primaryCode} / ${dualLaunchCodes.secondaryCode}`;
+ }
+
+ switch (script.targetConfig.mode) {
+ case "existing": {
+ return resolveTargetCode(script.targetConfig.selector, profiles) || "运行时传入";
+ }
+ case "create": {
+ return (
+ resolveTargetCode(script.targetConfig.templateSelector, profiles) ||
+ "运行时传入"
+ );
+ }
+ case "rotate": {
+ const code = resolveTargetCode(script.targetConfig.selector, profiles);
+ if (code) {
+ return code;
+ }
+ const selector = script.targetConfig.selector;
+ const hasFilter = Boolean(
+ normalizeText(selector.profileId) ||
+ normalizeText(selector.profileName) ||
+ normalizeText(selector.groupId) ||
+ selector.keywords.length > 0 ||
+ selector.tags.length > 0,
+ );
+ return hasFilter ? "条件匹配" : "运行时传入";
+ }
+ default: {
+ const selector = parseJSONObjectText(script.selectorText);
+ const directCode = normalizeCode(
+ typeof selector?.code === "string" ? selector.code : "",
+ );
+ const launchCode = normalizeCode(
+ typeof selector?.launchCode === "string" ? selector.launchCode : "",
+ );
+ return directCode || launchCode || "运行时传入";
+ }
+ }
+}
+
+export function buildAutomationCardPresentation(options: {
+ script: AutomationScriptRecord;
+ profiles: BrowserProfile[];
+ launchBaseUrl: string;
+ apiAuthEnabled: boolean;
+ apiAuthHeader: string;
+ dualLaunchCodes: DualLaunchCodes;
+ dualInstanceRunPayload: Record;
+ dualInstanceRunPayloadText: string;
+ dualInstanceRunCurlDemo: string;
+}): AutomationCardPresentation {
+ const { script } = options;
+ const isDualInstanceScript = script.id === DUAL_INSTANCE_SCRIPT_ID;
+ const requestPayload = isDualInstanceScript
+ ? options.dualInstanceRunPayload
+ : buildAutomationRequestPayload(script, options.profiles);
+ const requestPayloadText = isDualInstanceScript
+ ? options.dualInstanceRunPayloadText
+ : buildAutomationRequestPayloadText(requestPayload);
+ const requestCurlDemo = isDualInstanceScript
+ ? options.dualInstanceRunCurlDemo
+ : buildAutomationRunCurlDemo({
+ launchBaseUrl: options.launchBaseUrl,
+ apiAuthEnabled: options.apiAuthEnabled,
+ apiAuthHeader: options.apiAuthHeader,
+ payload: requestPayload,
+ });
+ const cardMode = buildAutomationCardMode(script);
+ const resolvedPublicAPI = resolveAutomationScriptPublicAPIConfig(script);
+
+ return {
+ key: script.id,
+ title: script.name,
+ scriptId: script.id,
+ scriptType: script.type,
+ modeLabel: getAutomationModeLabel(script.type),
+ description: buildAutomationShortDescription(script),
+ codeDisplay: buildAutomationCodeDisplay(
+ script,
+ options.profiles,
+ options.dualLaunchCodes,
+ ),
+ primaryActionLabel: cardMode === "skill" ? "Skill" : "cURL",
+ primaryActionText:
+ cardMode === "skill"
+ ? buildAutomationSkillPrompt(script, requestPayload)
+ : requestCurlDemo,
+ primaryActionSuccessMessage:
+ cardMode === "skill" ? "Skill 提示词已复制" : "模拟 cURL 已复制",
+ secondaryActionLabel: "JSON",
+ secondaryActionText: requestPayloadText,
+ secondaryActionSuccessMessage: "请求 JSON 已复制",
+ modeToneClass: getAutomationModeToneClass(script.type),
+ publicAPIEnabled: resolvedPublicAPI.enabled,
+ railClassName: getAutomationCardRailClass(script.id),
+ };
+}
+
+export function buildDualInstanceFallbackPresentation(options: {
+ dualLaunchCodes: DualLaunchCodes;
+ dualInstanceRunPayloadText: string;
+ dualInstanceRunCurlDemo: string;
+}): AutomationCardPresentation {
+ return {
+ key: `${DUAL_INSTANCE_SCRIPT_ID}-fallback`,
+ title: "双实例启动与 Runtime 切换",
+ scriptType: "launch-api",
+ modeLabel: "接口模式",
+ description: "启动双实例并切换 Runtime",
+ codeDisplay: `${options.dualLaunchCodes.primaryCode} / ${options.dualLaunchCodes.secondaryCode}`,
+ primaryActionLabel: "cURL",
+ primaryActionText: options.dualInstanceRunCurlDemo,
+ primaryActionSuccessMessage: "模拟 cURL 已复制",
+ secondaryActionLabel: "JSON",
+ secondaryActionText: options.dualInstanceRunPayloadText,
+ secondaryActionSuccessMessage: "请求 JSON 已复制",
+ modeToneClass: getAutomationModeToneClass("launch-api"),
+ publicAPIEnabled: false,
+ railClassName: getAutomationCardRailClass(DUAL_INSTANCE_SCRIPT_ID),
+ };
+}
+
+function collectAvailableLaunchCodes(profiles: BrowserProfile[]): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+
+ for (const profile of profiles) {
+ const code = normalizeCode(profile.launchCode);
+ if (!code || seen.has(code)) {
+ continue;
+ }
+ seen.add(code);
+ result.push(code);
+ }
+
+ return result;
+}
+
+export function resolveDualLaunchCodes(profiles: BrowserProfile[]): DualLaunchCodes {
+ const availableCodes = collectAvailableLaunchCodes(profiles);
+ if (availableCodes.length >= 2) {
+ return {
+ primaryCode: availableCodes[0],
+ secondaryCode: availableCodes[1],
+ };
+ }
+ if (availableCodes.length === 1) {
+ return {
+ primaryCode: availableCodes[0],
+ secondaryCode: "BUYER_002",
+ };
+ }
+
+ return {
+ primaryCode: "BUYER_001",
+ secondaryCode: "BUYER_002",
+ };
+}
+
+function buildCurlAuthHeaderLine(
+ apiAuthEnabled: boolean,
+ apiAuthHeader: string,
+): string {
+ if (!apiAuthEnabled) {
+ return "";
+ }
+ return ` -H "${apiAuthHeader}: " \\\n`;
+}
+
+export function buildPersistablePublicAPIConfig(
+ script: AutomationScriptRecord,
+): AutomationScriptPublicAPIConfig {
+ return prepareAutomationScriptPublicAPIConfigForSave({
+ ...script,
+ publicAPI: resolveAutomationScriptPublicAPIConfig(script),
+ });
+}
+
+export function mergeImportedScripts(
+ current: AutomationScriptRecord[],
+ imported: AutomationScriptRecord[],
+): AutomationScriptRecord[] {
+ const deduped = new Map(imported.map((item) => [item.id, item]));
+ return [...imported, ...current.filter((item) => !deduped.has(item.id))];
+}
+
diff --git a/frontend/src/modules/browser/pages/AutomationPage.tsx b/frontend/src/modules/browser/pages/AutomationPage.tsx
index 1aab8026..3c8374c0 100644
--- a/frontend/src/modules/browser/pages/AutomationPage.tsx
+++ b/frontend/src/modules/browser/pages/AutomationPage.tsx
@@ -1,36 +1,11 @@
-import {
- useEffect,
- useState,
- type KeyboardEvent,
- type ReactNode,
-} from "react";
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import {
- Link,
- Pencil,
- Play,
- History,
- PlusSquare,
- RefreshCw,
- Upload,
- Wrench,
-} from "lucide-react";
-import {
- Button,
- FormItem,
- Input,
- Modal,
- Select,
- Textarea,
- toast,
-} from "../../../shared/components";
+import { toast } from "../../../shared/components";
import { AutomationScriptHistoryModal } from "../components/AutomationScriptHistoryModal";
import { AutomationScriptPublicApiModal } from "../components/AutomationScriptPublicApiModal";
import { AutomationScriptRunModal } from "../components/AutomationScriptRunModal";
import { AutomationToolboxModal } from "../components/AutomationToolboxModal";
-import { fetchBrowserProfiles } from "../api";
import {
- fetchAutomationScripts,
importAutomationScriptFromGit,
importAutomationScriptFromLocalDirectory,
importAutomationScriptFromLocalFile,
@@ -40,683 +15,32 @@ import {
saveAutomationScript,
} from "../automationScriptApi";
import {
- AUTOMATION_SCRIPT_TYPE_OPTIONS,
createAutomationScriptDraft,
- findAutomationTargetProfile,
- prepareAutomationScriptPublicAPIConfigForSave,
- resolveAutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIConfig,
type AutomationScriptRecord,
type AutomationScriptType,
} from "../automationScripts";
import { useLaunchContext } from "../hooks/useLaunchContext";
-import type { BrowserProfile } from "../types";
-
-type ImportMode =
- | "text"
- | "local-file"
- | "local-dir"
- | "local-library"
- | "remote-url"
- | "git";
-const DUAL_INSTANCE_SCRIPT_ID = "dual-instance-runtime-switch";
-const NEWS_SCRIPT_ID = "news-query-txt";
-
-type DualLaunchCodes = {
- primaryCode: string;
- secondaryCode: string;
-};
-
-type AutomationCardPresentation = {
- key: string;
- title: string;
- scriptId?: string;
- scriptType: AutomationScriptType;
- modeLabel: string;
- description: string;
- codeDisplay: string;
- primaryActionLabel: string;
- primaryActionText: string;
- primaryActionSuccessMessage: string;
- secondaryActionLabel: string;
- secondaryActionText: string;
- secondaryActionSuccessMessage: string;
- modeToneClass: string;
- publicAPIEnabled: boolean;
- railClassName: string;
-};
-
-function getAutomationCardRailClass(seed: string): string {
- const palette = [
- "bg-[#8aa0b3]",
- "bg-[#8da79b]",
- "bg-[#929ab1]",
- "bg-[#aa9a8e]",
- "bg-[#8b9a9c]",
- ];
-
- let hash = 0;
- for (const char of seed) {
- hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
- }
-
- return palette[hash % palette.length];
-}
-
-function ScriptCardField({
- label,
- children,
-}: {
- label: string;
- children: ReactNode;
-}) {
- return (
-
-
- {label}
-
-
- {children}
-
-
- );
-}
-
-function AutomationScriptSummaryCard({
- card,
- onOpen,
- onRunScript,
- onRunAPI,
-}: {
- card: AutomationCardPresentation;
- onOpen?: () => void;
- onRunScript?: () => void;
- onRunAPI?: () => void;
-}) {
- const interactive = typeof onOpen === "function";
- const isInterfaceModeCard = card.scriptType === "launch-api";
- const actionButtonClassName =
- "!h-7 !w-[104px] shrink-0 justify-center whitespace-nowrap !rounded-md !border !border-black !bg-black !px-2.5 !text-xs !font-medium !leading-none !text-white !shadow-none hover:!border-[#1f1f1f] hover:!bg-[#1f1f1f] focus-visible:!ring-black disabled:!border-[#6b7280] disabled:!bg-[#6b7280] disabled:!text-white";
- const headerCopyButtonClassName =
- "!h-7 !w-[104px] shrink-0 justify-center whitespace-nowrap !rounded-md !border !border-black !bg-white !px-2.5 !text-xs !font-medium !leading-none !text-black !shadow-none hover:!border-black hover:!bg-[#f3f4f6] hover:!text-black focus-visible:!ring-black disabled:!border-[#6b7280] disabled:!bg-white disabled:!text-[#6b7280]";
- const scriptButtonClassName =
- actionButtonClassName;
- const apiSetupButtonClassName =
- actionButtonClassName;
- const interfaceExecuteButtonClassName =
- actionButtonClassName;
- const editButtonClassName =
- actionButtonClassName;
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (!interactive || !onOpen) {
- return;
- }
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- onOpen();
- }
- };
-
- return (
-
-
-
-
- {card.title}
-
-
-
-
-
- {isInterfaceModeCard ? (
- typeof onRunAPI === "function" || typeof onRunScript === "function" ? (
-
- ) : null
- ) : (
- <>
- {typeof onRunScript === "function" ? (
-
- ) : null}
- {typeof onRunAPI === "function" ? (
-
- ) : null}
- >
- )}
- {interactive ? (
-
- ) : null}
-
-
-
-
-
-
- {card.modeLabel}
-
-
-
-
- {card.codeDisplay}
-
-
-
-
- );
-}
-
-function normalizeText(value?: string): string {
- return String(value || "").trim();
-}
-
-function normalizeCode(value?: string): string {
- return normalizeText(value).toUpperCase();
-}
-
-function resolveTargetCode(
- selector: AutomationScriptRecord["targetConfig"]["selector"],
- profiles: BrowserProfile[],
-): string {
- const matched = findAutomationTargetProfile(selector, profiles);
- return normalizeCode(matched?.launchCode || selector.code);
-}
-
-async function copyToClipboard(text: string, successMessage: string) {
- try {
- await navigator.clipboard.writeText(text);
- toast.success(successMessage);
- } catch {
- toast.error("复制失败");
- }
-}
-
-function parseJSONObjectText(text?: string): Record | null {
- const normalized = normalizeText(text);
- if (!normalized) {
- return null;
- }
-
- try {
- const parsed = JSON.parse(normalized);
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- return parsed as Record;
- }
- } catch {
- return null;
- }
-
- return null;
-}
-
-function buildSelectorPayload(
- selector: AutomationScriptRecord["targetConfig"]["selector"],
- profiles: BrowserProfile[],
-): Record | null {
- const matched = findAutomationTargetProfile(selector, profiles);
- const payload: Record = {};
-
- const code = normalizeCode(matched?.launchCode || selector.code);
- const profileId = normalizeText(matched?.profileId || selector.profileId);
- const profileName = normalizeText(
- matched?.profileName || selector.profileName,
- );
- const groupId = normalizeText(selector.groupId);
-
- if (code) {
- payload.code = code;
- }
- if (profileId) {
- payload.profileId = profileId;
- }
- if (profileName) {
- payload.profileName = profileName;
- }
- if (groupId) {
- payload.groupId = groupId;
- }
- if (selector.keywords.length > 0) {
- payload.keywords = [...selector.keywords];
- }
- if (selector.tags.length > 0) {
- payload.tags = [...selector.tags];
- }
-
- return Object.keys(payload).length > 0 ? payload : null;
-}
-
-function buildAutomationRequestPayload(
- script: AutomationScriptRecord,
- profiles: BrowserProfile[],
-): Record {
- const payload: Record = {
- scriptId: script.id,
- };
- const params = parseJSONObjectText(script.paramsText);
-
- switch (script.targetConfig.mode) {
- case "existing":
- case "rotate": {
- const selector = buildSelectorPayload(script.targetConfig.selector, profiles);
- if (selector) {
- payload.selector = selector;
- }
- break;
- }
- case "create":
- payload.useScriptSelector = true;
- break;
- default: {
- const selector = parseJSONObjectText(script.selectorText);
- if (selector && Object.keys(selector).length > 0) {
- payload.selector = selector;
- } else if (script.type === "playwright-cdp") {
- payload.selector = { code: "YOUR_CODE" };
- }
- break;
- }
- }
-
- if (params && Object.keys(params).length > 0) {
- payload.params = params;
- } else {
- payload.useScriptParams = true;
- }
-
- return payload;
-}
-
-function buildAutomationRequestPayloadText(
- payload: Record,
-): string {
- return JSON.stringify(payload, null, 2);
-}
-
-function buildAutomationRunCurlDemo(options: {
- launchBaseUrl: string;
- apiAuthEnabled: boolean;
- apiAuthHeader: string;
- payload: Record;
-}): string {
- const authHeader = buildCurlAuthHeaderLine(
- options.apiAuthEnabled,
- options.apiAuthHeader,
- );
- return `curl -X POST ${options.launchBaseUrl}/api/automation/scripts/run \\
- -H "Content-Type: application/json" \\
-${authHeader} -d '${buildAutomationRequestPayloadText(options.payload)}'`;
-}
-
-function buildAutomationCardMode(
- script: AutomationScriptRecord,
-): "skill" | "api-sim" {
- return script.type === "playwright-cdp" ? "skill" : "api-sim";
-}
-
-function getAutomationModeLabel(type: AutomationScriptType): string {
- return type === "playwright-cdp" ? "脚本模式" : "接口模式";
-}
-
-function getAutomationModeToneClass(type: AutomationScriptType): string {
- return type === "playwright-cdp"
- ? "bg-[var(--color-text-primary)]"
- : "bg-[var(--color-text-secondary)]";
-}
-
-function buildAutomationSkillPrompt(
- script: AutomationScriptRecord,
- payload: Record,
-) {
- const lines = [
- "使用 ant-chrome-openclaw skill。",
- `执行预置脚本 ${script.id}(${script.name})。`,
- ];
-
- if (Object.prototype.hasOwnProperty.call(payload, "selector")) {
- lines.push(`selector: ${JSON.stringify(payload.selector)}`);
- } else if (payload.useScriptSelector) {
- lines.push("selector: 使用脚本默认值。");
- }
-
- if (Object.prototype.hasOwnProperty.call(payload, "params")) {
- lines.push(`params: ${JSON.stringify(payload.params)}`);
- } else if (payload.useScriptParams) {
- lines.push("params: 使用脚本默认值。");
- }
-
- return lines.join("\n");
-}
-
-function buildAutomationShortDescription(
- script: AutomationScriptRecord,
-): string {
- switch (script.id) {
- case DUAL_INSTANCE_SCRIPT_ID:
- return "启动双实例并切换 Runtime";
- case NEWS_SCRIPT_ID:
- return "搜索新闻并写入 TXT";
- default:
- break;
- }
-
- const source = normalizeText(script.description || script.name);
- const firstSentence = source.split(/[。!?\n]/)[0]?.trim() || "按预置流程执行自动化";
- const compact = firstSentence
- .replace(/^通过/, "")
- .replace(/^使用/, "")
- .replace(/^基于/, "")
- .replace(/浏览器实例/g, "实例")
- .replace(/本地 txt/gi, "TXT")
- .replace(/\s+/g, " ");
-
- return compact.length > 30 ? `${compact.slice(0, 28).trim()}...` : compact;
-}
-
-function buildAutomationCodeDisplay(
- script: AutomationScriptRecord,
- profiles: BrowserProfile[],
- dualLaunchCodes: DualLaunchCodes,
-): string {
- if (script.id === DUAL_INSTANCE_SCRIPT_ID) {
- return `${dualLaunchCodes.primaryCode} / ${dualLaunchCodes.secondaryCode}`;
- }
-
- switch (script.targetConfig.mode) {
- case "existing": {
- return resolveTargetCode(script.targetConfig.selector, profiles) || "运行时传入";
- }
- case "create": {
- return (
- resolveTargetCode(script.targetConfig.templateSelector, profiles) ||
- "运行时传入"
- );
- }
- case "rotate": {
- const code = resolveTargetCode(script.targetConfig.selector, profiles);
- if (code) {
- return code;
- }
- const selector = script.targetConfig.selector;
- const hasFilter = Boolean(
- normalizeText(selector.profileId) ||
- normalizeText(selector.profileName) ||
- normalizeText(selector.groupId) ||
- selector.keywords.length > 0 ||
- selector.tags.length > 0,
- );
- return hasFilter ? "条件匹配" : "运行时传入";
- }
- default: {
- const selector = parseJSONObjectText(script.selectorText);
- const directCode = normalizeCode(
- typeof selector?.code === "string" ? selector.code : "",
- );
- const launchCode = normalizeCode(
- typeof selector?.launchCode === "string" ? selector.launchCode : "",
- );
- return directCode || launchCode || "运行时传入";
- }
- }
-}
-
-function buildAutomationCardPresentation(options: {
- script: AutomationScriptRecord;
- profiles: BrowserProfile[];
- launchBaseUrl: string;
- apiAuthEnabled: boolean;
- apiAuthHeader: string;
- dualLaunchCodes: DualLaunchCodes;
- dualInstanceRunPayload: Record;
- dualInstanceRunPayloadText: string;
- dualInstanceRunCurlDemo: string;
-}): AutomationCardPresentation {
- const { script } = options;
- const isDualInstanceScript = script.id === DUAL_INSTANCE_SCRIPT_ID;
- const requestPayload = isDualInstanceScript
- ? options.dualInstanceRunPayload
- : buildAutomationRequestPayload(script, options.profiles);
- const requestPayloadText = isDualInstanceScript
- ? options.dualInstanceRunPayloadText
- : buildAutomationRequestPayloadText(requestPayload);
- const requestCurlDemo = isDualInstanceScript
- ? options.dualInstanceRunCurlDemo
- : buildAutomationRunCurlDemo({
- launchBaseUrl: options.launchBaseUrl,
- apiAuthEnabled: options.apiAuthEnabled,
- apiAuthHeader: options.apiAuthHeader,
- payload: requestPayload,
- });
- const cardMode = buildAutomationCardMode(script);
- const resolvedPublicAPI = resolveAutomationScriptPublicAPIConfig(script);
-
- return {
- key: script.id,
- title: script.name,
- scriptId: script.id,
- scriptType: script.type,
- modeLabel: getAutomationModeLabel(script.type),
- description: buildAutomationShortDescription(script),
- codeDisplay: buildAutomationCodeDisplay(
- script,
- options.profiles,
- options.dualLaunchCodes,
- ),
- primaryActionLabel: cardMode === "skill" ? "Skill" : "cURL",
- primaryActionText:
- cardMode === "skill"
- ? buildAutomationSkillPrompt(script, requestPayload)
- : requestCurlDemo,
- primaryActionSuccessMessage:
- cardMode === "skill" ? "Skill 提示词已复制" : "模拟 cURL 已复制",
- secondaryActionLabel: "JSON",
- secondaryActionText: requestPayloadText,
- secondaryActionSuccessMessage: "请求 JSON 已复制",
- modeToneClass: getAutomationModeToneClass(script.type),
- publicAPIEnabled: resolvedPublicAPI.enabled,
- railClassName: getAutomationCardRailClass(script.id),
- };
-}
-
-function buildDualInstanceFallbackPresentation(options: {
- dualLaunchCodes: DualLaunchCodes;
- dualInstanceRunPayloadText: string;
- dualInstanceRunCurlDemo: string;
-}): AutomationCardPresentation {
- return {
- key: `${DUAL_INSTANCE_SCRIPT_ID}-fallback`,
- title: "双实例启动与 Runtime 切换",
- scriptType: "launch-api",
- modeLabel: "接口模式",
- description: "启动双实例并切换 Runtime",
- codeDisplay: `${options.dualLaunchCodes.primaryCode} / ${options.dualLaunchCodes.secondaryCode}`,
- primaryActionLabel: "cURL",
- primaryActionText: options.dualInstanceRunCurlDemo,
- primaryActionSuccessMessage: "模拟 cURL 已复制",
- secondaryActionLabel: "JSON",
- secondaryActionText: options.dualInstanceRunPayloadText,
- secondaryActionSuccessMessage: "请求 JSON 已复制",
- modeToneClass: getAutomationModeToneClass("launch-api"),
- publicAPIEnabled: false,
- railClassName: getAutomationCardRailClass(DUAL_INSTANCE_SCRIPT_ID),
- };
-}
-
-function collectAvailableLaunchCodes(profiles: BrowserProfile[]): string[] {
- const seen = new Set();
- const result: string[] = [];
-
- for (const profile of profiles) {
- const code = normalizeCode(profile.launchCode);
- if (!code || seen.has(code)) {
- continue;
- }
- seen.add(code);
- result.push(code);
- }
-
- return result;
-}
-
-function resolveDualLaunchCodes(profiles: BrowserProfile[]): DualLaunchCodes {
- const availableCodes = collectAvailableLaunchCodes(profiles);
- if (availableCodes.length >= 2) {
- return {
- primaryCode: availableCodes[0],
- secondaryCode: availableCodes[1],
- };
- }
- if (availableCodes.length === 1) {
- return {
- primaryCode: availableCodes[0],
- secondaryCode: "BUYER_002",
- };
- }
-
- return {
- primaryCode: "BUYER_001",
- secondaryCode: "BUYER_002",
- };
-}
-
-function buildCurlAuthHeaderLine(
- apiAuthEnabled: boolean,
- apiAuthHeader: string,
-): string {
- if (!apiAuthEnabled) {
- return "";
- }
- return ` -H "${apiAuthHeader}: " \\\n`;
-}
-
-function buildPersistablePublicAPIConfig(
- script: AutomationScriptRecord,
-): AutomationScriptPublicAPIConfig {
- return prepareAutomationScriptPublicAPIConfigForSave({
- ...script,
- publicAPI: resolveAutomationScriptPublicAPIConfig(script),
- });
-}
-
-function mergeImportedScripts(
- current: AutomationScriptRecord[],
- imported: AutomationScriptRecord[],
-): AutomationScriptRecord[] {
- const deduped = new Map(imported.map((item) => [item.id, item]));
- return [...imported, ...current.filter((item) => !deduped.has(item.id))];
-}
-
+import { AutomationCardsSection } from "./AutomationCardsSection";
+import { AutomationPageHeader } from "./AutomationPageHeader";
+import { useAutomationPageData } from "./useAutomationPageData";
+import { CreateAutomationScriptModal, ImportAutomationScriptModal } from "./AutomationPageModals";
+import {
+ DUAL_INSTANCE_SCRIPT_ID,
+ buildAutomationCardPresentation,
+ buildAutomationRequestPayloadText,
+ buildAutomationRunCurlDemo,
+ buildDualInstanceFallbackPresentation,
+ buildPersistablePublicAPIConfig,
+ mergeImportedScripts,
+ resolveDualLaunchCodes,
+ type AutomationCardPresentation,
+ type ImportMode,
+} from "./AutomationPage.helpers";
export function AutomationPage() {
const navigate = useNavigate();
const { launchBaseUrl, apiAuth } = useLaunchContext();
- const [scripts, setScripts] = useState([]);
- const [profiles, setProfiles] = useState([]);
- const [loading, setLoading] = useState(true);
- const [refreshing, setRefreshing] = useState(false);
+ const { scripts, setScripts, profiles, loading, refreshing, handleRefresh } = useAutomationPageData();
const [historyOpen, setHistoryOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -742,48 +66,6 @@ export function AutomationPage() {
"none",
);
- useEffect(() => {
- let disposed = false;
-
- void fetchAutomationScripts()
- .then((items) => {
- if (!disposed) {
- setScripts(items);
- }
- })
- .catch(() => {
- toast.error("脚本列表加载失败");
- })
- .finally(() => {
- if (!disposed) {
- setLoading(false);
- }
- });
-
- return () => {
- disposed = true;
- };
- }, []);
-
- useEffect(() => {
- let disposed = false;
-
- void fetchBrowserProfiles()
- .then((items) => {
- if (!disposed) {
- setProfiles(items || []);
- }
- })
- .catch(() => {
- if (!disposed) {
- setProfiles([]);
- }
- });
-
- return () => {
- disposed = true;
- };
- }, []);
const openScript = (scriptId: string) => {
navigate(`/browser/automation/${scriptId}`);
@@ -869,38 +151,6 @@ export function AutomationPage() {
return Boolean(saved);
};
- const handleRefresh = async () => {
- if (refreshing) {
- return;
- }
-
- setRefreshing(true);
- try {
- const [scriptsResult, profilesResult] = await Promise.allSettled([
- fetchAutomationScripts(),
- fetchBrowserProfiles(),
- ]);
-
- if (scriptsResult.status === "fulfilled") {
- setScripts(scriptsResult.value);
- } else {
- toast.error("脚本列表刷新失败");
- }
-
- if (profilesResult.status === "fulfilled") {
- setProfiles(profilesResult.value || []);
- }
-
- if (
- scriptsResult.status === "fulfilled" &&
- profilesResult.status === "fulfilled"
- ) {
- toast.success("已刷新");
- }
- } finally {
- setRefreshing(false);
- }
- };
const resetCreateModal = () => {
setCreateType("playwright-cdp");
@@ -1077,294 +327,57 @@ export function AutomationPage() {
}),
...scriptCards,
];
- const scriptMap = new Map(scripts.map((script) => [script.id, script]));
-
return (
-
-
- 脚本管理
-
-
-
-
-
-
-
-
-
+
void handleRefresh()}
+ onCreate={() => setCreateOpen(true)}
+ onImport={() => setImportOpen(true)}
+ onOpenHistory={() => setHistoryOpen(true)}
+ onOpenToolbox={() => setToolboxOpen(true)}
+ />
-
- {loading ? (
-
- 正在加载脚本列表...
-
- ) : cards.length === 0 ? (
-
-
- 还没有脚本
-
-
- 先新建一套脚本,或者导入已有脚本。
-
-
-
-
-
-
- ) : (
-
- {cards.map((card) => {
- const scriptId = card.scriptId;
- const onOpen = scriptId ? () => openScript(scriptId) : undefined;
- const script = scriptId ? scriptMap.get(scriptId) : undefined;
- const publicAPIEnabled = script
- ? resolveAutomationScriptPublicAPIConfig(script).enabled
- : false;
- const onRunScript = script && script.type !== "launch-api"
- ? () => handleOpenRunModal(script)
- : undefined;
- const onRunAPI = script
- ? () =>
- handleOpenPublicApiModal(script, {
- focusTest: publicAPIEnabled,
- })
- : undefined;
+
setCreateOpen(true)}
+ onImport={() => setImportOpen(true)}
+ onOpenScript={openScript}
+ onRunAutomationScript={handleOpenRunModal}
+ onOpenPublicApi={handleOpenPublicApiModal}
+ />
- return (
-
- );
- })}
-
- )}
-
-
-
-
-
- >
- }
- >
-
-
- setCreateName(event.target.value)}
- placeholder="例如:接管页面并截图"
- />
-
-
-
-
-
+ onCreate={handleCreate}
+ onCreateNameChange={setCreateName}
+ onCreateTypeChange={setCreateType}
+ />
-
-
-
- >
- }
- >
-
-
- {[
- { value: "text", label: "文本" },
- { value: "local-file", label: "本地文件" },
- { value: "local-dir", label: "本地目录" },
- { value: "local-library", label: "脚本库" },
- { value: "remote-url", label: "远程 URL" },
- { value: "git", label: "Git" },
- ].map((item) => (
-
- ))}
-
-
- {importMode === "text" ? (
- <>
-
- 支持导入导出的脚本 JSON,导入后会按草稿保存。
-
-
- setImportText(event.target.value)}
- className="font-mono"
- placeholder='{"manifest":{"name":"示例脚本"}}'
- />
-
- >
- ) : null}
-
- {importMode === "local-file" ? (
-
- 导入时会弹出文件选择框。支持单个 `.js/.cjs/.mjs` 脚本文件、导出的
- `.json` 模板,或标准 `.zip` 脚本包。`.ts/.cts/.mts` 仅在设置页开启 TypeScript 导入构建后支持。
-
- ) : null}
-
- {importMode === "local-dir" ? (
-
- 导入时会弹出目录选择框。适合导入一整套本地脚本目录,或 Git 拉下来的脚本包目录。目录里的 `.ts/.cts/.mts` 入口也需要先在设置页开启 TypeScript 导入构建。
-
- ) : null}
-
- {importMode === "local-library" ? (
-
- 导入时会弹出目录选择框。系统会扫描所选目录下的脚本包并批量导入,来源按本地目录记录,后续刷新不走 Git。
-
- ) : null}
-
- {importMode === "remote-url" ? (
-
-
- 适合导入单个远程脚本文件、导出的脚本 JSON,或标准脚本 ZIP。多文件仓库也可以继续使用 Git 导入;远程 `.ts/.cts/.mts` 同样要求设置页已开启 TypeScript 导入构建。
-
-
- setRemoteURL(event.target.value)}
- placeholder="https://example.com/script.cjs"
- />
-
-
- ) : null}
-
- {importMode === "git" ? (
-
- ) : null}
-
-
+ onImport={handleImport}
+ onImportModeChange={setImportMode}
+ onImportTextChange={setImportText}
+ onRemoteURLChange={setRemoteURL}
+ onGitURLChange={setGitURL}
+ onGitRefChange={setGitRef}
+ onGitScriptPathChange={setGitScriptPath}
+ />
);
}
+
+
diff --git a/frontend/src/modules/browser/pages/AutomationPageHeader.tsx b/frontend/src/modules/browser/pages/AutomationPageHeader.tsx
new file mode 100644
index 00000000..52b52980
--- /dev/null
+++ b/frontend/src/modules/browser/pages/AutomationPageHeader.tsx
@@ -0,0 +1,68 @@
+import { History, PlusSquare, RefreshCw, Upload, Wrench } from "lucide-react";
+import { Button } from "../../../shared/components";
+
+interface AutomationPageHeaderProps {
+ refreshing: boolean;
+ onRefresh: () => void;
+ onCreate: () => void;
+ onImport: () => void;
+ onOpenHistory: () => void;
+ onOpenToolbox: () => void;
+}
+
+export function AutomationPageHeader({
+ refreshing,
+ onRefresh,
+ onCreate,
+ onImport,
+ onOpenHistory,
+ onOpenToolbox,
+}: AutomationPageHeaderProps) {
+ return (
+
+
+ 脚本管理
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/modules/browser/pages/AutomationPageModals.tsx b/frontend/src/modules/browser/pages/AutomationPageModals.tsx
new file mode 100644
index 00000000..d39480a9
--- /dev/null
+++ b/frontend/src/modules/browser/pages/AutomationPageModals.tsx
@@ -0,0 +1,239 @@
+import { Button, FormItem, Input, Modal, Select, Textarea } from "../../../shared/components";
+import { AUTOMATION_SCRIPT_TYPE_OPTIONS, type AutomationScriptType } from "../automationScripts";
+import type { ImportMode } from "./AutomationPage.helpers";
+
+interface CreateAutomationScriptModalProps {
+ open: boolean;
+ busyAction: "none" | "create" | "import";
+ createName: string;
+ createType: AutomationScriptType;
+ onClose: () => void;
+ onCreate: () => Promise;
+ onCreateNameChange: (value: string) => void;
+ onCreateTypeChange: (value: AutomationScriptType) => void;
+}
+
+export function CreateAutomationScriptModal({
+ open,
+ busyAction,
+ createName,
+ createType,
+ onClose,
+ onCreate,
+ onCreateNameChange,
+ onCreateTypeChange,
+}: CreateAutomationScriptModalProps) {
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ onCreateNameChange(event.target.value)}
+ placeholder="例如:接管页面并截图"
+ />
+
+
+
+
+
+ );
+}
+
+interface ImportAutomationScriptModalProps {
+ open: boolean;
+ busyAction: "none" | "create" | "import";
+ importMode: ImportMode;
+ importText: string;
+ remoteURL: string;
+ gitURL: string;
+ gitRef: string;
+ gitScriptPath: string;
+ onClose: () => void;
+ onImport: () => Promise;
+ onImportModeChange: (value: ImportMode) => void;
+ onImportTextChange: (value: string) => void;
+ onRemoteURLChange: (value: string) => void;
+ onGitURLChange: (value: string) => void;
+ onGitRefChange: (value: string) => void;
+ onGitScriptPathChange: (value: string) => void;
+}
+
+export function ImportAutomationScriptModal({
+ open,
+ busyAction,
+ importMode,
+ importText,
+ remoteURL,
+ gitURL,
+ gitRef,
+ gitScriptPath,
+ onClose,
+ onImport,
+ onImportModeChange,
+ onImportTextChange,
+ onRemoteURLChange,
+ onGitURLChange,
+ onGitRefChange,
+ onGitScriptPathChange,
+}: ImportAutomationScriptModalProps) {
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ {[
+ { value: "text", label: "文本" },
+ { value: "local-file", label: "本地文件" },
+ { value: "local-dir", label: "本地目录" },
+ { value: "local-library", label: "脚本库" },
+ { value: "remote-url", label: "远程 URL" },
+ { value: "git", label: "Git" },
+ ].map((item) => (
+
+ ))}
+
+
+ {importMode === "text" ? (
+ <>
+
+ 支持导入导出的脚本 JSON,导入后会按草稿保存。
+
+
+ onImportTextChange(event.target.value)}
+ className="font-mono"
+ placeholder='{"manifest":{"name":"示例脚本"}}'
+ />
+
+ >
+ ) : null}
+
+ {importMode === "local-file" ? (
+
+ 导入时会弹出文件选择框。支持单个 `.js/.cjs/.mjs` 脚本文件、导出的
+ `.json` 模板,或标准 `.zip` 脚本包。`.ts/.cts/.mts` 仅在设置页开启 TypeScript 导入构建后支持。
+
+ ) : null}
+
+ {importMode === "local-dir" ? (
+
+ 导入时会弹出目录选择框。适合导入一整套本地脚本目录,或 Git 拉下来的脚本包目录。目录里的 `.ts/.cts/.mts` 入口也需要先在设置页开启 TypeScript 导入构建。
+
+ ) : null}
+
+ {importMode === "local-library" ? (
+
+ 导入时会弹出目录选择框。系统会扫描所选目录下的脚本包并批量导入,来源按本地目录记录,后续刷新不走 Git。
+
+ ) : null}
+
+ {importMode === "remote-url" ? (
+
+
+ 适合导入单个远程脚本文件、导出的脚本 JSON,或标准脚本 ZIP。多文件仓库也可以继续使用 Git 导入;远程 `.ts/.cts/.mts` 同样要求设置页已开启 TypeScript 导入构建。
+
+
+ onRemoteURLChange(event.target.value)}
+ placeholder="https://example.com/script.cjs"
+ />
+
+
+ ) : null}
+
+ {importMode === "git" ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx b/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx
new file mode 100644
index 00000000..2b4a7e40
--- /dev/null
+++ b/frontend/src/modules/browser/pages/AutomationScriptSummaryCard.tsx
@@ -0,0 +1,217 @@
+import { type KeyboardEvent, type ReactNode } from "react";
+import { Link, Pencil, Play } from "lucide-react";
+import { Button } from "../../../shared/components";
+import type { AutomationCardPresentation } from "./AutomationPage.helpers";
+import { copyToClipboard } from "./AutomationPage.helpers";
+
+function ScriptCardField({
+ label,
+ children,
+}: {
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+export function AutomationScriptSummaryCard({
+ card,
+ onOpen,
+ onRunScript,
+ onRunAPI,
+}: {
+ card: AutomationCardPresentation;
+ onOpen?: () => void;
+ onRunScript?: () => void;
+ onRunAPI?: () => void;
+}) {
+ const interactive = typeof onOpen === "function";
+ const isInterfaceModeCard = card.scriptType === "launch-api";
+ const actionButtonClassName =
+ "!h-7 !w-full min-w-0 justify-center whitespace-nowrap !rounded-md !border !border-black !bg-black !px-2 !text-xs !font-medium !leading-none !text-white !shadow-none hover:!border-[#1f1f1f] hover:!bg-[#1f1f1f] focus-visible:!ring-black disabled:!border-[#6b7280] disabled:!bg-[#6b7280] disabled:!text-white";
+ const headerCopyButtonClassName =
+ "!h-7 !w-full min-w-0 justify-center whitespace-nowrap !rounded-md !border !border-black !bg-white !px-2 !text-xs !font-medium !leading-none !text-black !shadow-none hover:!border-black hover:!bg-[#f3f4f6] hover:!text-black focus-visible:!ring-black disabled:!border-[#6b7280] disabled:!bg-white disabled:!text-[#6b7280]";
+ const scriptButtonClassName =
+ actionButtonClassName;
+ const apiSetupButtonClassName =
+ actionButtonClassName;
+ const interfaceExecuteButtonClassName =
+ actionButtonClassName;
+ const editButtonClassName =
+ actionButtonClassName;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!interactive || !onOpen) {
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onOpen();
+ }
+ };
+
+ return (
+
+
+
+
+ {card.title}
+
+
+
+
+
+ {isInterfaceModeCard ? (
+ typeof onRunAPI === "function" || typeof onRunScript === "function" ? (
+
+ ) : null
+ ) : (
+ <>
+ {typeof onRunScript === "function" ? (
+
+ ) : null}
+ {typeof onRunAPI === "function" ? (
+
+ ) : null}
+ >
+ )}
+ {interactive ? (
+
+ ) : null}
+
+
+
+
+
+
+ {card.modeLabel}
+
+
+
+
+ {card.codeDisplay}
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/modules/browser/pages/BrowserListPage.tsx b/frontend/src/modules/browser/pages/BrowserListPage.tsx
index 954746bd..5566de75 100644
--- a/frontend/src/modules/browser/pages/BrowserListPage.tsx
+++ b/frontend/src/modules/browser/pages/BrowserListPage.tsx
@@ -1,107 +1,42 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useState } from 'react'
import { toast } from '../../../shared/components'
-import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from '../../dashboard/api'
-import type { BrowserCore, BrowserCoreInput, BrowserProfile, BrowserProfileCopyOptions, BrowserProxy, BrowserSettings, BrowserGroupWithCount } from '../types'
-import { BrowserCoreEditorModal, BrowserListHeader, BrowserListSettingsModal, type BrowserViewMode } from '../components/BrowserListLayout'
+import type { BrowserProfile, BrowserProfileCopyOptions } from '../types'
+import { BrowserCoreEditorModal, BrowserListHeader, BrowserListSettingsModal } from '../components/BrowserListLayout'
import { BatchToolbar } from '../components/BrowserListWidgets'
import { BrowserProfilesPanel } from '../components/BrowserProfilesPanel'
-import { EMPTY_FILTERS } from '../components/InstanceFilterBar'
-import type { InstanceFilters } from '../components/InstanceFilterBar'
import { createBrowserProfileCopyOptions, isBrowserProfileCopyOptionsValid } from '../copyOptions'
import { buildBrowserProfileCopyName } from '../copyName'
-import { EventsOn, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
-import { PROJECT_GITHUB_URL } from '../../../config/links'
-import { resolveActionErrorMessage, resolveActionFeedback } from '../utils/actionErrors'
+import { resolveActionFeedback } from '../utils/actionErrors'
import { BrowserListDialogs } from './browserList/BrowserListDialogs'
+import { useBrowserListDerived, useBrowserListViewState } from './browserList/useBrowserListViewState'
+import { useBrowserListSettings } from './browserList/useBrowserListSettings'
+import { useBrowserListData } from './browserList/useBrowserListData'
+import { useBrowserProfileActions } from './browserList/useBrowserProfileActions'
import {
copyBrowserProfile,
- deleteBrowserCore,
deleteBrowserProfile,
- fetchBrowserCores,
- fetchBrowserProfiles,
- fetchBrowserProxies,
- fetchBrowserSettings,
- fetchGroups,
- restartBrowserInstance,
- saveBrowserCore,
- saveBrowserSettings,
- setDefaultBrowserCore,
startBrowserInstance,
- startBrowserInstanceDirect,
stopBrowserInstance,
- validateBrowserCorePath,
- validateProxyConfig,
} from '../api'
-const resolveProfileStatus = (running: boolean, debugReady: boolean, starting: boolean, stopping: boolean) => {
- if (starting) {
- return { variant: 'info' as const, label: '启动中' }
- }
- if (stopping) {
- return { variant: 'default' as const, label: '停止中' }
- }
- if (running && !debugReady) {
- return { variant: 'info' as const, label: '运行中(待就绪)' }
- }
- if (running) {
- return { variant: 'success' as const, label: '运行中' }
- }
- return { variant: 'warning' as const, label: '已停止' }
-}
-
export function BrowserListPage() {
- const [profiles, setProfiles] = useState([])
- const [loading, setLoading] = useState(true)
- const [proxies, setProxies] = useState([])
- const [groups, setGroups] = useState([])
+ const {
+ viewMode,
+ setViewMode,
+ filters,
+ setFilters,
+ headerCollapsed,
+ setHeaderCollapsed,
+ } = useBrowserListViewState()
- // 视图模式
- const [viewMode, setViewMode] = useState(() => {
- return (localStorage.getItem('browser:viewMode') as BrowserViewMode) || 'table'
- })
-
- // 勾选状态
const [selectedIds, setSelectedIds] = useState>(new Set())
const [batchLoading, setBatchLoading] = useState(false)
- // 筛选状态(从 localStorage 恢复)
- const [filters, setFilters] = useState(() => {
- try {
- const saved = localStorage.getItem('browser:filters')
- if (saved) {
- const parsed = JSON.parse(saved)
- return { ...EMPTY_FILTERS, ...parsed, tags: new Set(parsed.tags || []) }
- }
- } catch { /* ignore */ }
- return EMPTY_FILTERS
- })
- const [headerCollapsed, setHeaderCollapsed] = useState(() => {
- return localStorage.getItem('browser:headerCollapsed') === 'true'
- })
-
- // 持久化筛选状态
- useEffect(() => {
- const serializable = { ...filters, tags: Array.from(filters.tags) }
- localStorage.setItem('browser:filters', JSON.stringify(serializable))
- }, [filters])
-
- useEffect(() => {
- localStorage.setItem('browser:viewMode', viewMode)
- }, [viewMode])
-
- useEffect(() => {
- localStorage.setItem('browser:headerCollapsed', String(headerCollapsed))
- }, [headerCollapsed])
-
// 代理不支持弹窗
const [proxyErrorModal, setProxyErrorModal] = useState(false)
const [proxyErrorMsg, setProxyErrorMsg] = useState('')
const [opError, setOpError] = useState('')
const [pendingStartId, setPendingStartId] = useState(null)
- const [startingIds, setStartingIds] = useState>(new Set())
- const [stoppingIds, setStoppingIds] = useState>(new Set())
- const profilesRef = useRef([])
- const silentRefreshInFlightRef = useRef(false)
// 关键字弹窗
const [kwModal, setKwModal] = useState<{ open: boolean; profile: BrowserProfile | null }>({ open: false, profile: null })
@@ -125,374 +60,87 @@ export function BrowserListPage() {
setCopyName('')
setCopyOptions(createBrowserProfileCopyOptions())
}
-
- // 基础配置弹窗
- const [settingsModalOpen, setSettingsModalOpen] = useState(false)
- const [settings, setSettings] = useState({
- userDataRoot: 'data',
- defaultFingerprintArgs: [],
- defaultLaunchArgs: [],
- defaultStartUrls: [],
- lightStartEnabled: true,
- restoreLastSession: false,
- startReadyTimeoutMs: 3000,
- startStableWindowMs: 1200,
+ const {
+ settingsModalOpen,
+ setSettingsModalOpen,
+ settings,
+ setSettings,
+ fingerprintText,
+ setFingerprintText,
+ launchText,
+ setLaunchText,
+ startUrlsText,
+ setStartUrlsText,
+ savingSettings,
+ cores,
+ coreModalOpen,
+ setCoreModalOpen,
+ coreForm,
+ setCoreForm,
+ coreValidation,
+ setCoreValidation,
+ savingCore,
+ expandModalOpen,
+ setExpandModalOpen,
+ cdKey,
+ setCdKey,
+ redeeming,
+ maxProfileLimit,
+ loadCores,
+ loadQuota,
+ handleOpenSettings,
+ handleSaveSettings,
+ handleOpenCoreModal,
+ handleValidateCorePath,
+ handleSaveCore,
+ handleDeleteCore,
+ handleSetDefaultCore,
+ handleRedeem,
+ handleOpenGithubStarGift,
+ } = useBrowserListSettings()
+ const {
+ profiles,
+ loading,
+ proxies,
+ groups,
+ startingIds,
+ stoppingIds,
+ setStartingIds,
+ setStoppingIds,
+ updatePendingIds,
+ updateProfilesState,
+ mergeProfileState,
+ loadProfiles,
+ } = useBrowserListData({ loadQuota, loadCores })
+ const {
+ runningCount,
+ allTags,
+ filteredProfiles,
+ resolveProfileCore,
+ getProfileCoreLabel,
+ isProfileStarting,
+ isProfileStopping,
+ isProfileBusy,
+ getProfileStatus,
+ } = useBrowserListDerived(profiles, cores, filters, startingIds, stoppingIds)
+ const {
+ handleStart,
+ handleStartDirect,
+ handleStop,
+ handleRestart,
+ handleDelete,
+ } = useBrowserProfileActions({
+ profiles,
+ setProxyErrorModal,
+ setProxyErrorMsg,
+ setPendingStartId,
+ setOpError,
+ setStartingIds,
+ setStoppingIds,
+ updatePendingIds,
+ mergeProfileState,
+ loadProfiles,
})
- const [fingerprintText, setFingerprintText] = useState('')
- const [launchText, setLaunchText] = useState('')
- const [startUrlsText, setStartUrlsText] = useState('')
- const [savingSettings, setSavingSettings] = useState(false)
-
- // 内核管理
- const [cores, setCores] = useState([])
- const [coreModalOpen, setCoreModalOpen] = useState(false)
- const [coreForm, setCoreForm] = useState({ coreId: '', coreName: '', corePath: '', isDefault: false })
- const [coreValidation, setCoreValidation] = useState<{ valid: boolean; message: string } | null>(null)
- const [savingCore, setSavingCore] = useState(false)
-
- // 扩容管理
- const [expandModalOpen, setExpandModalOpen] = useState(false)
- const [cdKey, setCdKey] = useState('')
- const [redeeming, setRedeeming] = useState(false)
- const [maxProfileLimit, setMaxProfileLimit] = useState(20)
-
- const updatePendingIds = (
- setter: React.Dispatch>>,
- profileId: string,
- active: boolean
- ) => {
- setter(prev => {
- const next = new Set(prev)
- if (active) {
- next.add(profileId)
- } else {
- next.delete(profileId)
- }
- return next
- })
- }
-
- const replaceProfilesState = (items: BrowserProfile[]) => {
- profilesRef.current = items
- setProfiles(items)
- }
-
- const updateProfilesState = (updater: (items: BrowserProfile[]) => BrowserProfile[]) => {
- const next = updater(profilesRef.current)
- profilesRef.current = next
- setProfiles(next)
- }
-
- const mergeProfileState = (profile: BrowserProfile | null | undefined) => {
- if (!profile) return
- updateProfilesState(prev => prev.map(item => (
- item.profileId === profile.profileId ? { ...item, ...profile } : item
- )))
- }
-
- const syncProfiles = (items: BrowserProfile[], syncRuntimeState: boolean) => {
- if (syncRuntimeState) {
- const previousById = new Map(profilesRef.current.map(item => [item.profileId, item]))
- const newlyRunning = items.find(item => item.running && !previousById.get(item.profileId)?.running)
- if (newlyRunning) {
- updatePendingIds(setStartingIds, newlyRunning.profileId, false)
- updatePendingIds(setStoppingIds, newlyRunning.profileId, false)
- }
- items.forEach(item => {
- if (!item.running && previousById.get(item.profileId)?.running) {
- updatePendingIds(setStartingIds, item.profileId, false)
- updatePendingIds(setStoppingIds, item.profileId, false)
- }
- })
- }
- replaceProfilesState(items)
- }
-
- const loadProfiles = async ({ silent = false, syncRuntimeState = false }: { silent?: boolean; syncRuntimeState?: boolean } = {}) => {
- if (silent && silentRefreshInFlightRef.current) {
- return profilesRef.current
- }
- if (!silent) {
- setLoading(true)
- } else {
- silentRefreshInFlightRef.current = true
- }
- try {
- const items = await fetchBrowserProfiles()
- syncProfiles(items, syncRuntimeState)
- return items
- } finally {
- if (silent) {
- silentRefreshInFlightRef.current = false
- } else {
- setLoading(false)
- }
- }
- }
-
- const loadGroups = async () => {
- setGroups(await fetchGroups())
- }
-
- const loadSettings = async () => {
- const data = await fetchBrowserSettings()
- setSettings(data)
- setFingerprintText((data.defaultFingerprintArgs || []).join('\n'))
- setLaunchText((data.defaultLaunchArgs || []).join('\n'))
- setStartUrlsText((data.defaultStartUrls || []).join('\n'))
- }
-
- const loadCores = async () => {
- setCores(await fetchBrowserCores())
- }
-
- const loadQuota = async () => {
- try {
- await reloadConfig()
- const stats = await fetchDashboardStats()
- setMaxProfileLimit(stats.maxProfileLimit || 20)
- } catch {
- // ignore
- }
- }
-
- useEffect(() => {
- void loadProfiles()
- loadGroups()
- loadQuota()
- fetchBrowserProxies().then(setProxies)
- fetchBrowserCores().then(setCores)
-
- // 监听浏览器实例生命周期事件,自动更新状态
- const offStarted = EventsOn('browser:instance:started', (payload: any) => {
- const profileId = typeof payload === 'string' ? payload : payload?.profileId
- if (profileId) {
- updatePendingIds(setStartingIds, profileId, false)
- updatePendingIds(setStoppingIds, profileId, false)
- }
- void loadProfiles({ silent: true, syncRuntimeState: true })
- })
- const offUpdated = EventsOn('browser:instance:updated', () => {
- void loadProfiles({ silent: true, syncRuntimeState: true })
- })
- const offStopped = EventsOn('browser:instance:stopped', (payload: any) => {
- const profileId = typeof payload === 'string' ? payload : payload?.profileId
- if (profileId) {
- updatePendingIds(setStartingIds, profileId, false)
- updatePendingIds(setStoppingIds, profileId, false)
- }
- void loadProfiles({ silent: true, syncRuntimeState: true })
- })
- const offCrashed = EventsOn('browser:instance:crashed', (payload: any) => {
- const profileId = typeof payload === 'string' ? payload : payload?.profileId
- if (profileId) {
- updatePendingIds(setStartingIds, profileId, false)
- updatePendingIds(setStoppingIds, profileId, false)
- }
- void loadProfiles({ silent: true, syncRuntimeState: true })
- })
-
- const timer = window.setInterval(() => {
- if (document.visibilityState !== 'visible') return
- void loadProfiles({ silent: true, syncRuntimeState: true })
- }, 2000)
-
- return () => {
- window.clearInterval(timer)
- offStarted?.()
- offUpdated?.()
- offStopped?.()
- offCrashed?.()
- }
- }, [])
-
- const runningCount = useMemo(() => profiles.filter(p => p.running).length, [profiles])
- const allTags = useMemo(() => {
- const set = new Set()
- profiles.forEach(p => p.tags?.forEach(t => set.add(t)))
- return Array.from(set).sort()
- }, [profiles])
-
- const defaultCore = useMemo(() => {
- return cores.find(core => core.isDefault) || cores[0] || null
- }, [cores])
-
- const resolveProfileCore = (profile: BrowserProfile) => {
- const coreId = (profile.coreId || '').trim()
- if (coreId && !/^default$/i.test(coreId)) {
- return cores.find(core => core.coreId === coreId) || null
- }
- return defaultCore
- }
-
- const getProfileCoreLabel = (profile: BrowserProfile) => {
- const resolvedCore = resolveProfileCore(profile)
- if (resolvedCore) {
- return resolvedCore.coreName
- }
-
- const coreId = (profile.coreId || '').trim()
- if (!coreId || /^default$/i.test(coreId)) {
- return '使用默认内核'
- }
- return coreId
- }
-
- const isProfileStarting = (profileId: string) => startingIds.has(profileId)
- const isProfileStopping = (profileId: string) => stoppingIds.has(profileId)
- const isProfileBusy = (profileId: string) => isProfileStarting(profileId) || isProfileStopping(profileId)
-
- const getProfileStatus = (profile: BrowserProfile) => (
- resolveProfileStatus(profile.running, profile.debugReady, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId))
- )
-
- const filteredProfiles = useMemo(() => {
- const naturalCompare = (a: string, b: string): number => {
- const re = /(\d+)|(\D+)/g
- const partsA = a.match(re) || []
- const partsB = b.match(re) || []
- for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
- if (i >= partsA.length) return -1
- if (i >= partsB.length) return 1
- const pa = partsA[i], pb = partsB[i]
- const na = Number(pa), nb = Number(pb)
- if (!isNaN(na) && !isNaN(nb)) {
- if (na !== nb) return na - nb
- } else {
- const cmp = pa.localeCompare(pb, 'zh-CN')
- if (cmp !== 0) return cmp
- }
- }
- return 0
- }
- return profiles.filter(p => {
- // 分组筛选
- if (filters.groupId === '__ungrouped__' && p.groupId) return false
- if (filters.groupId && filters.groupId !== '__ungrouped__' && p.groupId !== filters.groupId) return false
-
- if (filters.keyword && !p.profileName.toLowerCase().includes(filters.keyword.toLowerCase())) return false
- if (filters.status === 'running' && !p.running) return false
- if (filters.status === 'stopped' && p.running) return false
- if (filters.proxyId === '__none__' && (p.proxyId || p.proxyConfig)) return false
- if (filters.proxyId && filters.proxyId !== '__none__' && p.proxyId !== filters.proxyId) return false
- if (filters.coreId) {
- const effectiveCore = resolveProfileCore(p)
- if (!effectiveCore || effectiveCore.coreId !== filters.coreId) return false
- }
- if (filters.tags.size > 0 && !p.tags?.some(t => filters.tags.has(t))) return false
- if (filters.kwSearch) {
- const q = filters.kwSearch.toLowerCase()
- const hit = p.keywords?.some(v => v.toLowerCase().includes(q))
- if (!hit) return false
- }
- return true
- }).sort((a, b) => naturalCompare(a.profileName, b.profileName))
- }, [profiles, filters, defaultCore, cores])
-
- const handleStart = async (profileId: string) => {
- const profile = profiles.find(p => p.profileId === profileId)
- updatePendingIds(setStartingIds, profileId, true)
- try {
- if (profile) {
- const result = await validateProxyConfig(profile.proxyConfig || '', profile.proxyId || '')
- if (!result.supported) {
- setProxyErrorMsg(result.errorMsg)
- setPendingStartId(profileId)
- setProxyErrorModal(true)
- return
- }
- }
-
- const startedProfile = await startBrowserInstance(profileId)
- mergeProfileState(startedProfile)
- if (startedProfile?.runtimeWarning) {
- toast.warning(startedProfile.runtimeWarning)
- } else {
- toast.success(`实例已启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`)
- }
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } catch (error: any) {
- const feedback = resolveActionFeedback(error, '实例启动失败')
- if (feedback.tone === 'warning') {
- toast.warning(feedback.message)
- } else {
- toast.error(feedback.message)
- }
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } finally {
- updatePendingIds(setStartingIds, profileId, false)
- }
- }
-
- const handleStartDirect = async (profileId: string) => {
- updatePendingIds(setStartingIds, profileId, true)
- try {
- const startedProfile = await startBrowserInstanceDirect(profileId)
- mergeProfileState(startedProfile)
- setProxyErrorModal(false)
- setPendingStartId(null)
- if (startedProfile?.runtimeWarning) {
- toast.warning(startedProfile.runtimeWarning)
- } else {
- toast.success(`实例已直连启动${startedProfile?.profileName ? `:${startedProfile.profileName}` : ''}`)
- }
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } catch (error: any) {
- setProxyErrorModal(false)
- setPendingStartId(null)
- const feedback = resolveActionFeedback(error, '实例直连启动失败')
- if (feedback.tone === 'warning') {
- toast.warning(feedback.message)
- } else {
- toast.error(feedback.message)
- }
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } finally {
- updatePendingIds(setStartingIds, profileId, false)
- }
- }
-
- const handleStop = async (profileId: string) => {
- updatePendingIds(setStoppingIds, profileId, true)
- try {
- const stoppedProfile = await stopBrowserInstance(profileId)
- mergeProfileState(stoppedProfile)
- toast.success('实例已停止')
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } catch (error: any) {
- toast.error(resolveActionErrorMessage(error, '实例停止失败'))
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } finally {
- updatePendingIds(setStoppingIds, profileId, false)
- }
- }
-
- const handleRestart = async (profileId: string) => {
- updatePendingIds(setStoppingIds, profileId, true)
- try {
- const restartedProfile = await restartBrowserInstance(profileId)
- mergeProfileState(restartedProfile)
- toast.success(`实例已重启${restartedProfile?.profileName ? `:${restartedProfile.profileName}` : ''}`)
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } catch (error: any) {
- const feedback = resolveActionFeedback(error, '实例重启失败')
- if (feedback.tone === 'warning') {
- toast.warning(feedback.message)
- } else {
- setOpError(feedback.message)
- }
- await loadProfiles({ silent: true, syncRuntimeState: true })
- } finally {
- updatePendingIds(setStoppingIds, profileId, false)
- }
- }
-
- const handleDelete = async (profileId: string) => {
- await deleteBrowserProfile(profileId)
- toast.success('配置已删除')
- loadProfiles()
- }
-
// 批量操作
const toggleSelect = (profileId: string) => {
setSelectedIds(prev => {
@@ -614,115 +262,6 @@ export function BrowserListPage() {
const copyConfirmDisabled =
!copyName.trim() || !isBrowserProfileCopyOptionsValid(copyOptions)
- const handleOpenSettings = async () => {
- await Promise.all([loadSettings(), loadCores()])
- setSettingsModalOpen(true)
- }
-
- const handleSaveSettings = async () => {
- setSavingSettings(true)
- try {
- await saveBrowserSettings({
- ...settings,
- defaultFingerprintArgs: fingerprintText.split('\n').map(s => s.trim()).filter(Boolean),
- defaultLaunchArgs: launchText.split('\n').map(s => s.trim()).filter(Boolean),
- defaultStartUrls: startUrlsText.split('\n').map(s => s.trim()).filter(Boolean),
- })
- toast.success('配置已保存')
- setSettingsModalOpen(false)
- } catch (error: any) {
- toast.error(error?.message || '保存失败')
- } finally {
- setSavingSettings(false)
- }
- }
-
- // 内核管理
- const handleOpenCoreModal = (core?: BrowserCore) => {
- setCoreForm(core ? { ...core } : { coreId: '', coreName: '', corePath: '', isDefault: false })
- setCoreValidation(null)
- setCoreModalOpen(true)
- }
-
- const handleValidateCorePath = async () => {
- if (!coreForm.corePath.trim()) {
- setCoreValidation({ valid: false, message: '请输入路径' })
- return
- }
- const result = await validateBrowserCorePath(coreForm.corePath)
- setCoreValidation(result)
- }
-
- const handleSaveCore = async () => {
- if (!coreForm.coreName.trim()) {
- toast.error('请输入内核名称')
- return
- }
- if (!coreForm.corePath.trim()) {
- toast.error('请输入内核路径')
- return
- }
- setSavingCore(true)
- try {
- await saveBrowserCore(coreForm)
- toast.success('内核已保存')
- setCoreModalOpen(false)
- loadCores()
- } catch (error: any) {
- toast.error(error?.message || '保存失败')
- } finally {
- setSavingCore(false)
- }
- }
-
- const handleDeleteCore = async (coreId: string) => {
- if (cores.length <= 1) {
- toast.error('至少保留一个内核')
- return
- }
- await deleteBrowserCore(coreId)
- toast.success('内核已删除')
- loadCores()
- }
-
- const handleSetDefaultCore = async (coreId: string) => {
- await setDefaultBrowserCore(coreId)
- toast.success('已设为默认')
- loadCores()
- }
-
- const handleRedeem = async () => {
- if (!cdKey.trim()) return
- setRedeeming(true)
- const result = await redeemCDKey(cdKey.trim())
- setRedeeming(false)
- if (result.success) {
- toast.success('兑换成功!此名额已到账')
- setCdKey('')
- loadQuota()
- } else {
- toast.error(result.message || '兑换失败')
- }
- }
-
- const handleClaimStarGift = async () => {
- setRedeeming(true)
- const starRes = await redeemGithubStar()
- setRedeeming(false)
- if (starRes.success) {
- toast.success('感谢您的支持!已额外赠送 50 个永久额度!')
- setCdKey('')
- loadQuota()
- } else {
- toast.error(starRes.message || '领取失败')
- }
- }
-
- const handleOpenGithubStarGift = async () => {
- BrowserOpenURL(PROJECT_GITHUB_URL)
- await handleClaimStarGift()
- }
-
return (
diff --git a/frontend/src/modules/browser/pages/CoreManagementPage.tsx b/frontend/src/modules/browser/pages/CoreManagementPage.tsx
index f460bd96..ec938a20 100644
--- a/frontend/src/modules/browser/pages/CoreManagementPage.tsx
+++ b/frontend/src/modules/browser/pages/CoreManagementPage.tsx
@@ -1,21 +1,15 @@
import { useEffect, useState, useCallback } from 'react'
-import { FolderOpen, Settings, Edit2 } from 'lucide-react'
-import { Badge, Button, Card, ConfirmModal, FormItem, Input, Modal, Switch, Table, Textarea, toast } from '../../../shared/components'
+import { FolderOpen } from 'lucide-react'
+import { Badge, Button, Card, ConfirmModal, Table, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserCore, BrowserCoreInput, BrowserCoreValidateResult, BrowserSettings, BrowserCoreExtended, BrowserProxy } from '../types'
import { fetchBrowserCores, saveBrowserCore, deleteBrowserCore, setDefaultBrowserCore, validateBrowserCorePath, openCorePath, fetchBrowserSettings, saveBrowserSettings, fetchCoreExtendedInfo, scanBrowserCores, BrowserCoreDownload, fetchBrowserProxies } from '../api'
-import { EventsOn, EventsOff, BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
-
-interface CoreDisplayInfo {
- coreId: string
- coreName: string
- corePath: string
- isDefault: boolean
- pathValid: boolean
- pathMessage: string
- chromeVersion: string
- instanceCount: number
-}
+import { EventsOn, EventsOff } from '../../../wailsjs/runtime/runtime'
+import { CoreDownloadModal } from './coreManagement/CoreDownloadModal'
+import { CoreEditModal } from './coreManagement/CoreEditModal'
+import { CoreSettingsCard } from './coreManagement/CoreSettingsCard'
+import { CoreSettingsModal } from './coreManagement/CoreSettingsModal'
+import type { CoreDisplayInfo, CoreDownloadForm, CoreDownloadProgress, CoreEditForm, CoreSettingsForm } from './coreManagement.types'
export function CoreManagementPage() {
const [cores, setCores] = useState([])
@@ -35,7 +29,7 @@ export function CoreManagementPage() {
startStableWindowMs: 1200,
})
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
- const [settingsForm, setSettingsForm] = useState({
+ const [settingsForm, setSettingsForm] = useState({
userDataRoot: '',
defaultFingerprintArgs: '',
defaultLaunchArgs: '',
@@ -50,7 +44,7 @@ export function CoreManagementPage() {
// 编辑弹窗状态
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingCore, setEditingCore] = useState(null)
- const [editForm, setEditForm] = useState({ coreName: '', corePath: '' })
+ const [editForm, setEditForm] = useState({ coreName: '', corePath: '' })
const [saving, setSaving] = useState(false)
const [pathValidating, setPathValidating] = useState(false)
const [pathValidResult, setPathValidResult] = useState(null)
@@ -61,8 +55,8 @@ export function CoreManagementPage() {
// 内核下载
const [downloadModalOpen, setDownloadModalOpen] = useState(false)
- const [downloadForm, setDownloadForm] = useState({ name: '', url: '', proxyMode: 'system', proxyId: '' })
- const [downloadProgress, setDownloadProgress] = useState<{ phase: string; progress: number; message: string } | null>(null)
+ const [downloadForm, setDownloadForm] = useState({ name: '', url: '', proxyMode: 'system', proxyId: '' })
+ const [downloadProgress, setDownloadProgress] = useState(null)
const [proxies, setProxies] = useState([])
useEffect(() => {
@@ -406,71 +400,7 @@ export function CoreManagementPage() {
- {/* 全局设置卡片 */}
-
-
-
-
-
全局设置
-
-
-
-
-
-
用户数据根目录
-
{settings.userDataRoot || '-'}
-
-
-
默认指纹参数
- {settings.defaultFingerprintArgs.length > 0 ? (
-
- {settings.defaultFingerprintArgs.join('\n')}
-
- ) : (
-
-
- )}
-
-
-
默认启动参数
- {settings.defaultLaunchArgs.length > 0 ? (
-
- {settings.defaultLaunchArgs.join('\n')}
-
- ) : (
-
-
- )}
-
-
-
默认启动页面
- {settings.defaultStartUrls.length > 0 ? (
-
- {settings.defaultStartUrls.join('\n')}
-
- ) : (
-
-
- )}
-
-
-
恢复上次标签页
-
{settings.restoreLastSession ? '开启' : '关闭'}
-
-
-
轻启动模式
-
{settings.lightStartEnabled ? '开启' : '关闭'}
-
-
-
启动就绪超时
-
{settings.startReadyTimeoutMs} ms
-
-
-
启动稳定窗口
-
{settings.startStableWindowMs} ms
-
-
-
+
{/* 内核列表卡片 */}
@@ -483,135 +413,26 @@ export function CoreManagementPage() {
/>
- {/* 全局设置编辑弹窗 */}
- setSettingsModalOpen(false)}
- title="编辑全局设置"
- width="550px"
- footer={
- <>
-
-
- >
- }
- >
-
-
- setSettingsForm(prev => ({ ...prev, userDataRoot: e.target.value }))}
- placeholder="例如:data"
- />
-
-
- setSettingsForm(prev => ({ ...prev, defaultFingerprintArgs: e.target.value }))}
- rows={4}
- placeholder="每行一个参数,如 --fingerprint-brand=Chrome"
- />
-
-
- setSettingsForm(prev => ({ ...prev, defaultLaunchArgs: e.target.value }))}
- rows={4}
- placeholder="每行一个参数,如 --disable-sync"
- />
-
-
- setSettingsForm(prev => ({ ...prev, defaultStartUrls: e.target.value }))}
- rows={4}
- placeholder="启动 URL"
- />
-
-
-
- 延后打开启动页
- setSettingsForm(prev => ({ ...prev, lightStartEnabled: checked }))}
- />
-
-
-
-
-
-
允许恢复旧 tab
-
关闭后,下次启动会继续恢复之前的标签页和窗口。
-
-
setSettingsForm(prev => ({ ...prev, restoreLastSession: checked }))}
- />
-
-
-
-
- setSettingsForm(prev => ({ ...prev, startReadyTimeoutMs: Math.max(1000, Number(e.target.value) || 3000) }))}
- placeholder="3000"
- />
-
-
- setSettingsForm(prev => ({ ...prev, startStableWindowMs: Math.max(0, Number(e.target.value) || 1200) }))}
- placeholder="1200"
- />
-
-
-
-
+ onSave={handleSaveSettings}
+ />
- {/* 新增/编辑内核弹窗 */}
- setEditModalOpen(false)}
- title={editingCore ? '编辑内核' : '新增内核'}
- width="500px"
- footer={
- <>
-
-
- >
- }
- >
-
-
+ onSave={handleSaveCore}
+ />
{/* 删除确认弹窗 */}
- {/* 内核下载弹窗 */}
- {
- if (downloadProgress && downloadProgress.phase !== 'done' && downloadProgress.phase !== 'error') {
- toast.warning('正在下载中,请稍候...')
- return
- }
- setDownloadModalOpen(false)
- setDownloadProgress(null)
- }} title="下载内核" width="480px"
- footer={
- <>
-
-
- >
- }>
-
-
- setDownloadForm(prev => ({ ...prev, name: e.target.value }))}
- placeholder="例如: chrome-139"
- disabled={downloadProgress !== null}
- />
- 该名称将同时作为数据存放的子文件夹名。
-
-
- setDownloadForm(prev => ({ ...prev, url: e.target.value }))}
- placeholder="https://github.com/.../release.zip"
- disabled={downloadProgress !== null}
- />
-
- 推荐指纹内核: fingerprint-chromium
-
-
-
-
-
-
-
-
- {downloadForm.proxyMode === 'custom' && (
-
-
-
- )}
-
- {downloadProgress && (
-
-
- {downloadProgress.message}
- {downloadProgress.progress}%
-
-
-
- )}
-
-
-
+ setDownloadModalOpen(false)}
+ onStart={handleStartDownloadCore}
+ />
)
}
diff --git a/frontend/src/modules/browser/pages/ProxyPoolPage.tsx b/frontend/src/modules/browser/pages/ProxyPoolPage.tsx
index 88047e28..52754ab1 100644
--- a/frontend/src/modules/browser/pages/ProxyPoolPage.tsx
+++ b/frontend/src/modules/browser/pages/ProxyPoolPage.tsx
@@ -1,51 +1,17 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { ConfirmModal, toast } from '../../../shared/components'
import type { SortOrder } from '../../../shared/components/Table'
-import type { BrowserProxy, ProxyCheckSettings, ProxyIPHealthResult } from '../types'
-import { createDefaultProxyCheckSettings, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies, browserProxyTestSpeed, browserProxyBatchTestSpeed, browserProxyCheckIPHealth, browserProxyBatchCheckIPHealth, fetchClashImportFromURL, fetchProxyCheckSettings, saveProxyCheckSettings } from '../api'
-import { EventsOn } from '../../../wailsjs/runtime/runtime'
+import type { BrowserProxy, ProxyIPHealthResult } from '../types'
+import { fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
import {
- BUILTIN_PROXY_IDS,
- CHAIN_QUICK_IMPORT_TEMPLATE,
- DIRECT_QUICK_IMPORT_TEMPLATE,
- INITIAL_CHAIN_IMPORT_FORM,
- INITIAL_DIRECT_IMPORT_FORM,
buildChainImportCandidate,
- buildDirectImportCandidate,
- buildDirectImportCandidatesFromText,
- buildImportCandidatesFromClash,
- buildImportPreview,
- buildRefreshedSourceProxies,
- collectURLImportSources,
- createExistingProxyIDPicker,
+ createInitialChainImportForm,
ensureBuiltinProxies,
- normalizeRefreshIntervalM,
- parseClashImportText,
- parseChainImportJSON,
- parseDirectImportText,
- parseTimestampMs,
- nextProxyID,
- resolveImportSourceID,
toChainImportForm,
toDisplayList,
type ChainImportForm,
- type DirectImportForm,
type ProxyDisplayInfo,
- type ProxyImportMode,
- type URLImportSourceMeta,
} from './proxyPool/helpers'
-import {
- appendSourceIgnoredProxyNames,
- applyIgnoredProxyNamesForSource,
- readGlobalRefreshConfig,
- readIPHealthCache,
- readLatencyCache,
- readSourceIgnoredProxyNames,
- toLatencyValue,
- writeGlobalRefreshConfig,
- writeIPHealthCache,
- writeLatencyCache,
-} from './proxyPool/storage'
import {
ProxyPoolEditModal,
ProxyPoolIPHealthDetailModal,
@@ -55,14 +21,16 @@ import {
} from './proxyPool/ProxyPoolModals'
import { ProxyPoolHeader } from './proxyPool/ProxyPoolHeader'
import { ProxyPoolTableCard } from './proxyPool/ProxyPoolTableCard'
+import { ProxyPoolCheckSettingsModal } from './proxyPool/ProxyPoolCheckSettingsModal'
+import { useProxySourceRefresh } from './proxyPool/useProxySourceRefresh'
+import { useProxyImportFlow } from './proxyPool/useProxyImportFlow'
+import { useProxyChecks } from './proxyPool/useProxyChecks'
+import { useProxySelection } from './proxyPool/useProxySelection'
+import { useProxyCheckSettingsModal } from './proxyPool/useProxyCheckSettingsModal'
+import { useProxyGlobalRefreshConfig } from './proxyPool/useProxyGlobalRefreshConfig'
+import { useProxyDeleteFlow } from './proxyPool/useProxyDeleteFlow'
export function ProxyPoolPage() {
- const createInitialChainImportForm = (): ChainImportForm => ({
- ...INITIAL_CHAIN_IMPORT_FORM,
- first: { ...INITIAL_CHAIN_IMPORT_FORM.first },
- second: { ...INITIAL_CHAIN_IMPORT_FORM.second },
- })
-
const [proxies, setProxies] = useState([])
const [displayList, setDisplayList] = useState([])
const [loading, setLoading] = useState(true)
@@ -74,40 +42,25 @@ export function ProxyPoolPage() {
const [sortColumn, setSortColumn] = useState('') // 默认不排序
const [sortOrder, setSortOrder] = useState(undefined)
- const [latencyMap, setLatencyMap] = useState>({})
- const [testingAll, setTestingAll] = useState(false)
- const [ipHealthMap, setIPHealthMap] = useState>({})
- const [checkingIPHealthIds, setCheckingIPHealthIds] = useState>(new Set())
- const [checkingAllIPHealth, setCheckingAllIPHealth] = useState(false)
- const [checkSettingsOpen, setCheckSettingsOpen] = useState(false)
- const [checkSettings, setCheckSettings] = useState(() => createDefaultProxyCheckSettings())
- const [checkTargetsText, setCheckTargetsText] = useState('')
- const [savingCheckSettings, setSavingCheckSettings] = useState(false)
+ const {
+ checkSettingsOpen,
+ setCheckSettingsOpen,
+ checkSettings,
+ setCheckSettings,
+ checkTargetsText,
+ setCheckTargetsText,
+ savingCheckSettings,
+ openCheckSettings,
+ saveCheckSettings,
+ } = useProxyCheckSettingsModal()
- const [selectedIds, setSelectedIds] = useState>(new Set())
- const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
-
- const [importModalOpen, setImportModalOpen] = useState(false)
- const [importMode, setImportMode] = useState('clash')
- const [importUrl, setImportUrl] = useState('')
- const [importResolvedUrl, setImportResolvedUrl] = useState('')
- const [importText, setImportText] = useState('')
- const [importDnsServers, setImportDnsServers] = useState('')
- const [importNamePrefix, setImportNamePrefix] = useState('')
- const [importGroupName, setImportGroupName] = useState('')
- const [chainImportText, setChainImportText] = useState('')
- const [directImportText, setDirectImportText] = useState('')
- const [chainImportForm, setChainImportForm] = useState(() => createInitialChainImportForm())
- const [directImportForm, setDirectImportForm] = useState(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
- const [previewModalOpen, setPreviewModalOpen] = useState(false)
- const [previewList, setPreviewList] = useState([])
- const [removedPreviewProxyNames, setRemovedPreviewProxyNames] = useState([])
- const [importing, setImporting] = useState(false)
- const [fetchingImportUrl, setFetchingImportUrl] = useState(false)
- const [refreshingAllSources, setRefreshingAllSources] = useState(false)
- const [refreshingSourceIds, setRefreshingSourceIds] = useState>(new Set())
- const [globalAutoRefreshEnabled, setGlobalAutoRefreshEnabled] = useState(false)
- const [globalRefreshIntervalM, setGlobalRefreshIntervalM] = useState('60')
+ const {
+ globalAutoRefreshEnabled,
+ setGlobalAutoRefreshEnabled,
+ globalRefreshInterval,
+ globalRefreshIntervalM,
+ setGlobalRefreshIntervalM,
+ } = useProxyGlobalRefreshConfig()
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingProxy, setEditingProxy] = useState(null)
@@ -121,76 +74,10 @@ export function ProxyPoolPage() {
})
const [saving, setSaving] = useState(false)
- const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
- const [deletingId, setDeletingId] = useState(null)
- const [ipHealthDetailOpen, setIPHealthDetailOpen] = useState(false)
- const [currentIPHealthDetail, setCurrentIPHealthDetail] = useState(null)
- const proxiesRef = useRef([])
- const refreshingSourceIdsRef = useRef>(new Set())
- const autoRefreshRunningRef = useRef(false)
- const globalRefreshInterval = useMemo(() => {
- const interval = normalizeRefreshIntervalM(Number(globalRefreshIntervalM || 0))
- return interval > 0 ? interval : 60
- }, [globalRefreshIntervalM])
-
useEffect(() => {
- const cfg = readGlobalRefreshConfig()
- setGlobalAutoRefreshEnabled(cfg.enabled)
- setGlobalRefreshIntervalM(String(cfg.intervalM))
- setLatencyMap(readLatencyCache())
- setIPHealthMap(readIPHealthCache())
loadProxies()
}, [])
- useEffect(() => {
- writeLatencyCache(latencyMap)
- }, [latencyMap])
-
- useEffect(() => {
- writeIPHealthCache(ipHealthMap)
- }, [ipHealthMap])
-
- useEffect(() => {
- writeGlobalRefreshConfig(globalAutoRefreshEnabled, globalRefreshInterval)
- }, [globalAutoRefreshEnabled, globalRefreshInterval])
-
- useEffect(() => {
- proxiesRef.current = proxies
- }, [proxies])
-
- useEffect(() => {
- refreshingSourceIdsRef.current = refreshingSourceIds
- }, [refreshingSourceIds])
-
- useEffect(() => {
- if (!proxies.length) return
- const validIds = new Set(proxies.map(p => p.proxyId))
- setLatencyMap(prev => {
- let changed = false
- const next: Record = {}
- Object.entries(prev).forEach(([proxyId, latency]) => {
- if (validIds.has(proxyId)) {
- next[proxyId] = latency
- } else {
- changed = true
- }
- })
- return changed ? next : prev
- })
-
- setIPHealthMap(prev => {
- let changed = false
- const next: Record = {}
- Object.entries(prev).forEach(([proxyId, health]) => {
- if (validIds.has(proxyId)) {
- next[proxyId] = health
- } else {
- changed = true
- }
- })
- return changed ? next : prev
- })
- }, [proxies])
const loadProxies = async () => {
setLoading(true)
@@ -228,26 +115,6 @@ export function ProxyPoolPage() {
}
}
- const openCheckSettings = async () => {
- const settings = await fetchProxyCheckSettings()
- setCheckSettings(settings)
- setCheckTargetsText(JSON.stringify(settings.targets || [], null, 2))
- setCheckSettingsOpen(true)
- }
-
- const saveCheckSettings = async () => {
- setSavingCheckSettings(true)
- try {
- const targets = JSON.parse(checkTargetsText || '[]')
- await saveProxyCheckSettings({ ...checkSettings, targets })
- toast.success('检测设置已保存')
- setCheckSettingsOpen(false)
- } catch (error: any) {
- toast.error(error?.message || '检测设置保存失败')
- } finally {
- setSavingCheckSettings(false)
- }
- }
// 直接保存完整列表,内置代理保护由后端负责
const saveProxies = useCallback(async (list: BrowserProxy[]) => {
@@ -259,131 +126,53 @@ export function ProxyPoolPage() {
setGroups(grps)
}, [])
- const sourceMetas = useMemo(() => collectURLImportSources(proxies), [proxies])
- const hasURLImportSources = sourceMetas.length > 0
+ const {
+ importModalOpen, setImportModalOpen, importMode, importUrl, importResolvedUrl, importText,
+ importDnsServers, importNamePrefix, importGroupName, chainImportText, directImportText,
+ chainImportForm, directImportForm, previewModalOpen, setPreviewModalOpen, previewList, removedPreviewProxyNames,
+ importing, fetchingImportUrl, canParseImport, setImportText, setImportDnsServers,
+ setImportNamePrefix, setImportGroupName, setChainImportText, setDirectImportText,
+ setChainImportForm, setDirectImportForm, handleRemovePreviewProxy, updateChainImportHop,
+ handleImportModeChange, handleFillChainTemplate, handleFillDirectTemplate, handleCopyChainTemplate,
+ handleCopyDirectTemplate, handleApplyChainJSON, handleApplyDirectText, handleImportUrlChange,
+ handleFetchImportURL, handleParseImport, handleConfirmImport,
+ } = useProxyImportFlow({
+ proxies,
+ globalAutoRefreshEnabled,
+ globalRefreshInterval,
+ saveProxies,
+ })
- const refreshSingleSource = useCallback(async (sourceId: string, silent: boolean) => {
- const currentList = proxiesRef.current
- const metas = collectURLImportSources(currentList)
- const meta = metas.find(item => item.sourceId === sourceId)
- if (!meta) return false
+ const {
+ hasURLImportSources,
+ refreshingAllSources,
+ refreshingSourceIds,
+ refreshSingleSource,
+ handleRefreshAllSources,
+ } = useProxySourceRefresh({
+ proxies,
+ globalAutoRefreshEnabled,
+ globalRefreshInterval,
+ saveProxies,
+ })
- if (refreshingSourceIdsRef.current.has(sourceId)) return false
- setRefreshingSourceIds(prev => {
- const next = new Set(prev)
- next.add(sourceId)
- return next
- })
-
- try {
- const result = await fetchClashImportFromURL(meta.sourceUrl)
- const parsed = parseClashImportText(result.content || '')
- if (!parsed.length) {
- throw new Error('订阅内容未解析到可用代理')
- }
- const ignoredNameMap = readSourceIgnoredProxyNames()
- const sourceIgnoredNames = ignoredNameMap[sourceId] || []
- const filteredParsed = applyIgnoredProxyNamesForSource(parsed, meta.sourceNamePrefix, sourceIgnoredNames)
-
- const latest = proxiesRef.current
- const oldSourceProxies = latest.filter(item => (item.sourceId || '').trim() === sourceId)
- const refreshedAt = new Date().toISOString()
- const effectiveMeta: URLImportSourceMeta = {
- ...meta,
- sourceAutoRefresh: globalAutoRefreshEnabled,
- sourceRefreshIntervalM: globalRefreshInterval,
- }
- const refreshedSourceProxies = buildRefreshedSourceProxies(filteredParsed, oldSourceProxies, effectiveMeta, refreshedAt)
-
- const merged = latest
- .filter(item => (item.sourceId || '').trim() !== sourceId)
- .concat(refreshedSourceProxies)
-
- await saveProxies(merged)
- if (!silent) {
- toast.success(`订阅刷新成功:${meta.sourceUrl}(${refreshedSourceProxies.length} 条)`)
- }
- return true
- } catch (error: any) {
- if (!silent) {
- toast.error(error?.message || '订阅刷新失败')
- }
- return false
- } finally {
- setRefreshingSourceIds(prev => {
- const next = new Set(prev)
- next.delete(sourceId)
- return next
- })
- }
- }, [globalAutoRefreshEnabled, globalRefreshInterval, saveProxies])
-
- const handleRefreshAllSources = useCallback(async (silent = false) => {
- const metas = collectURLImportSources(proxiesRef.current)
- if (metas.length === 0) {
- if (!silent) {
- toast.info('当前没有 URL 导入订阅')
- }
- return
- }
-
- setRefreshingAllSources(true)
- let successCount = 0
- for (const meta of metas) {
- // 串行刷新,避免并发保存导致覆盖
- // eslint-disable-next-line no-await-in-loop
- const ok = await refreshSingleSource(meta.sourceId, true)
- if (ok) successCount += 1
- }
- setRefreshingAllSources(false)
-
- if (!silent) {
- if (successCount === metas.length) {
- toast.success(`订阅刷新完成:${successCount}/${metas.length}`)
- } else {
- toast.warning(`订阅刷新完成:成功 ${successCount}/${metas.length}`)
- }
- }
- }, [refreshSingleSource])
-
- useEffect(() => {
- const runAutoRefresh = async () => {
- if (autoRefreshRunningRef.current || refreshingAllSources) {
- return
- }
- if (!globalAutoRefreshEnabled) {
- return
- }
- const intervalMs = globalRefreshInterval * 60 * 1000
- const metas = collectURLImportSources(proxiesRef.current).filter(meta => {
- if (!meta.sourceUrl.trim()) return false
- const last = parseTimestampMs(meta.sourceLastRefreshAt)
- return last <= 0 || Date.now() - last >= intervalMs
- })
- if (metas.length === 0) {
- return
- }
-
- autoRefreshRunningRef.current = true
- try {
- for (const meta of metas) {
- // eslint-disable-next-line no-await-in-loop
- await refreshSingleSource(meta.sourceId, true)
- }
- } finally {
- autoRefreshRunningRef.current = false
- }
- }
-
- void runAutoRefresh()
- const timer = window.setInterval(() => {
- void runAutoRefresh()
- }, 60 * 1000)
-
- return () => {
- window.clearInterval(timer)
- }
- }, [globalAutoRefreshEnabled, globalRefreshInterval, refreshingAllSources, refreshSingleSource])
+ const {
+ latencyMap,
+ testingAll,
+ ipHealthMap,
+ checkingIPHealthIds,
+ checkingAllIPHealth,
+ ipHealthDetailOpen,
+ setIPHealthDetailOpen,
+ currentIPHealthDetail,
+ setLatencyMap,
+ setIPHealthMap,
+ handleTestOne,
+ handleTestAll,
+ handleCheckOneIPHealth,
+ handleCheckAllIPHealth,
+ openIPHealthDetail,
+ } = useProxyChecks({ proxies })
const protocolOptions = useMemo(
() => ['all', ...Array.from(new Set(displayList.map(p => p.type).filter(t => t !== '-')))],
@@ -413,7 +202,9 @@ export function ProxyPoolPage() {
case 'server':
return compareText(a.server || '', b.server || '')
case 'port':
- return (a.port || 0) - (b.port || 0)
+
+
+ return (a.port || 0) - (b.port || 0)
case 'latency': {
const [rankA, valA] = getLatencySortTuple(a.proxyId)
const [rankB, valB] = getLatencySortTuple(b.proxyId)
@@ -442,179 +233,18 @@ export function ProxyPoolPage() {
})
}, [displayList, filterProtocol, filterKeyword, filterGroup, sortColumn, sortOrder, latencyMap])
- const allFilteredSelected = filteredList.length > 0 && filteredList.every(p => selectedIds.has(p.proxyId))
- const someFilteredSelected = filteredList.some(p => selectedIds.has(p.proxyId))
-
- const handleToggleAll = () => {
- if (allFilteredSelected) {
- setSelectedIds(prev => {
- const next = new Set(prev)
- filteredList.forEach(p => next.delete(p.proxyId))
- return next
- })
- } else {
- setSelectedIds(prev => {
- const next = new Set(prev)
- filteredList.filter(p => !BUILTIN_PROXY_IDS.has(p.proxyId)).forEach(p => next.add(p.proxyId))
- return next
- })
- }
- }
-
- const handleToggleOne = (proxyId: string) => {
- if (BUILTIN_PROXY_IDS.has(proxyId)) return
- setSelectedIds(prev => {
- const next = new Set(prev)
- next.has(proxyId) ? next.delete(proxyId) : next.add(proxyId)
- return next
- })
- }
-
- const handleBatchDeleteConfirm = async () => {
- try {
- const newProxies = proxies.filter(p => !selectedIds.has(p.proxyId))
- await saveProxies(newProxies)
- toast.success(`已删除 ${selectedIds.size} 个代理`)
- setSelectedIds(new Set())
- } catch (error: any) {
- toast.error(error?.message || '删除失败')
- }
- }
-
- const handleTestOne = async (record: ProxyDisplayInfo) => {
- if (record.proxyConfig === 'direct://') {
- toast.info('直连模式无需测速')
- return
- }
- setLatencyMap(prev => ({ ...prev, [record.proxyId]: -1 }))
- const result = await browserProxyTestSpeed(record.proxyId)
- const val = toLatencyValue(result.ok, result.latencyMs, result.error)
- setLatencyMap(prev => ({ ...prev, [record.proxyId]: val }))
- }
-
- const handleTestAll = async () => {
- const testable = filteredList.filter(p => p.proxyConfig !== 'direct://')
- if (testable.length === 0) return
- setTestingAll(true)
- const init: Record = {}
- testable.forEach(p => { init[p.proxyId] = -1 })
- setLatencyMap(prev => ({ ...prev, ...init }))
-
- // 监听后端实时推送的单个测速结果
- const off = EventsOn('proxy:speed:result', (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
- const val = toLatencyValue(data.ok, data.latencyMs, data.error)
- setLatencyMap(prev => ({ ...prev, [data.proxyId]: val }))
- })
-
- try {
- const proxyIds = testable.map(p => p.proxyId)
- const results = await browserProxyBatchTestSpeed(proxyIds, 20)
- setLatencyMap(prev => {
- const next = { ...prev }
- results.forEach(result => {
- next[result.proxyId] = toLatencyValue(result.ok, result.latencyMs, result.error)
- })
- return next
- })
- } finally {
- off()
- setTestingAll(false)
- }
- }
-
- const handleCheckOneIPHealth = async (record: ProxyDisplayInfo) => {
- if (record.proxyConfig === 'direct://') {
- toast.info('直连模式无需检测')
- return
- }
- if (checkingIPHealthIds.has(record.proxyId)) return
-
- setCheckingIPHealthIds(prev => new Set(prev).add(record.proxyId))
- try {
- const result = await browserProxyCheckIPHealth(record.proxyId)
- setIPHealthMap(prev => ({ ...prev, [record.proxyId]: result }))
- if (!result.ok) {
- toast.error(result.error || `${record.proxyName} 检测失败`)
- }
- } finally {
- setCheckingIPHealthIds(prev => {
- const next = new Set(prev)
- next.delete(record.proxyId)
- return next
- })
- }
- }
-
- const handleCheckAllIPHealth = async () => {
- const testable = filteredList.filter(p => p.proxyConfig !== 'direct://')
- if (testable.length === 0) return
- setCheckingAllIPHealth(true)
-
- const ids = testable.map(p => p.proxyId)
- const idSet = new Set(ids)
- setCheckingIPHealthIds(prev => new Set([...Array.from(prev), ...ids]))
-
- const off = EventsOn('proxy:iphealth:result', (data: ProxyIPHealthResult) => {
- if (!data?.proxyId || !idSet.has(data.proxyId)) return
- setIPHealthMap(prev => ({ ...prev, [data.proxyId]: data }))
- setCheckingIPHealthIds(prev => {
- const next = new Set(prev)
- next.delete(data.proxyId)
- return next
- })
- })
-
- try {
- const results = await browserProxyBatchCheckIPHealth(ids, 10)
- setIPHealthMap(prev => {
- const next = { ...prev }
- results.forEach(result => {
- if (result?.proxyId && idSet.has(result.proxyId)) {
- next[result.proxyId] = result
- }
- })
- return next
- })
- const failed = results.filter(r => !r.ok).length
- if (failed > 0) {
- toast.info(`IP 健康检测完成:成功 ${results.length - failed},失败 ${failed}`)
- } else {
- toast.success(`IP 健康检测完成:共 ${results.length} 条`)
- }
- } finally {
- off()
- setCheckingIPHealthIds(prev => {
- const next = new Set(prev)
- ids.forEach(id => next.delete(id))
- return next
- })
- setCheckingAllIPHealth(false)
- }
- }
-
- const openIPHealthDetail = (proxyId: string) => {
- const result = ipHealthMap[proxyId]
- if (!result) return
- setCurrentIPHealthDetail(result)
- setIPHealthDetailOpen(true)
- }
-
- const handleRemovePreviewProxy = (proxyId: string) => {
- const target = previewList.find(item => item.proxyId === proxyId)
- if (!target) return
- setPreviewList(prev => prev.filter(item => item.proxyId !== proxyId))
- setRemovedPreviewProxyNames(prev => [...prev, target.proxyName])
- }
-
- const updateChainImportHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
- setChainImportForm(prev => ({
- ...prev,
- [hop]: {
- ...prev[hop],
- [field]: value,
- },
- }))
- }
+ const {
+ selectedIds,
+ selectedCount,
+ allFilteredSelected,
+ someFilteredSelected,
+ batchDeleteConfirmOpen,
+ setBatchDeleteConfirmOpen,
+ handleToggleAll,
+ handleToggleOne,
+ handleBatchDeleteConfirm,
+ removeSelectedId,
+ } = useProxySelection({ proxies, filteredList, saveProxies })
const updateChainEditHop = (hop: 'first' | 'second', field: keyof ChainImportForm['first'], value: string) => {
setChainEditForm(prev => ({
@@ -685,245 +315,27 @@ export function ProxyPoolPage() {
}
}
- const handleDeleteClick = (proxyId: string) => {
- setDeletingId(proxyId)
- setDeleteConfirmOpen(true)
- }
- const handleDeleteConfirm = async () => {
- if (!deletingId) return
- try {
- const newProxies = proxies.filter(p => p.proxyId !== deletingId)
- await saveProxies(newProxies)
- setSelectedIds(prev => { const next = new Set(prev); next.delete(deletingId); return next })
- toast.success('代理已删除')
- } catch (error: any) {
- toast.error(error?.message || '删除失败')
- }
- setDeletingId(null)
- }
- const handleImportModeChange = (nextMode: ProxyImportMode) => {
- setImportMode(nextMode)
- setImportResolvedUrl('')
- if (nextMode !== 'clash') {
- setImportUrl('')
- setImportDnsServers('')
- }
- }
- const handleFillChainTemplate = () => {
- setChainImportText(CHAIN_QUICK_IMPORT_TEMPLATE)
- }
- const handleFillDirectTemplate = () => {
- setDirectImportText(DIRECT_QUICK_IMPORT_TEMPLATE)
- }
-
- const handleCopyChainTemplate = async () => {
- try {
- if (!navigator?.clipboard?.writeText) {
- throw new Error('当前环境不支持剪贴板')
- }
- await navigator.clipboard.writeText(CHAIN_QUICK_IMPORT_TEMPLATE)
- toast.success('JSON 模板已复制')
- } catch (error: any) {
- toast.error(error?.message || '复制模板失败')
- }
- }
-
- const handleCopyDirectTemplate = async () => {
- try {
- if (!navigator?.clipboard?.writeText) {
- throw new Error('当前环境不支持剪贴板')
- }
- await navigator.clipboard.writeText(DIRECT_QUICK_IMPORT_TEMPLATE)
- toast.success('JSON 模板已复制')
- } catch (error: any) {
- toast.error(error?.message || '复制模板失败')
- }
- }
-
- const handleApplyChainJSON = () => {
- try {
- const { form, groupName } = parseChainImportJSON(chainImportText)
- setChainImportForm(form)
- setImportGroupName(groupName)
- toast.success('JSON 已应用')
- } catch (error: any) {
- toast.error(error?.message || 'JSON 应用失败')
- }
- }
-
- const handleApplyDirectText = () => {
- try {
- const { form, groupName } = parseDirectImportText(directImportText)
- setDirectImportForm(form)
- if (groupName) {
- setImportGroupName(groupName)
- }
- setDirectImportText('')
- toast.success('文本已应用')
- } catch (error: any) {
- toast.error(error?.message || '文本应用失败')
- }
- }
-
- const handleImportUrlChange = (nextValue: string) => {
- setImportUrl(nextValue)
- if (importResolvedUrl.trim() && nextValue.trim() !== importResolvedUrl.trim()) {
- setImportResolvedUrl('')
- }
- }
-
- const handleFetchImportURL = async () => {
- const targetURL = importUrl.trim()
- if (!targetURL) {
- toast.error('请输入订阅 URL')
- return
- }
-
- setFetchingImportUrl(true)
- try {
- const result = await fetchClashImportFromURL(targetURL)
- const content = (result?.content || '').trim()
- if (!content) {
- throw new Error('订阅内容为空')
- }
-
- setImportResolvedUrl((result?.url || targetURL).trim())
- setImportText(content)
-
- if (!importDnsServers.trim() && typeof result?.dnsServers === 'string' && result.dnsServers.trim()) {
- setImportDnsServers(result.dnsServers.trim())
- }
- if (!importGroupName.trim() && typeof result?.suggestedGroup === 'string' && result.suggestedGroup.trim()) {
- setImportGroupName(result.suggestedGroup.trim())
- }
-
- toast.success(`URL 获取成功,检测到 ${Math.max(0, Number(result?.proxyCount || 0))} 个代理`)
- } catch (error: any) {
- setImportResolvedUrl('')
- toast.error(error?.message || 'URL 获取失败')
- } finally {
- setFetchingImportUrl(false)
- }
- }
-
- const handleParseImport = () => {
- try {
- const prefix = importNamePrefix.trim()
- let candidates
- let previewGroupName = importGroupName.trim()
- if (importMode === 'clash') {
- candidates = buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
- } else if (importMode === 'direct') {
- if (directImportText.trim()) {
- const parsed = buildDirectImportCandidatesFromText(directImportText)
- candidates = parsed.candidates
- if (!previewGroupName) {
- previewGroupName = parsed.defaultGroupName
- }
- } else {
- candidates = [buildDirectImportCandidate(directImportForm)]
- }
- } else {
- candidates = [buildChainImportCandidate(chainImportForm)]
- }
- if (!candidates.length) {
- toast.error('未解析到可导入代理')
- return
- }
- const preview = buildImportPreview(candidates, previewGroupName)
- setRemovedPreviewProxyNames([])
- setPreviewList(preview)
- setImportModalOpen(false)
- setPreviewModalOpen(true)
- } catch (error: any) {
- toast.error(`解析失败: ${error?.message || '未知错误'}`)
- }
- }
-
- const handleConfirmImport = async () => {
- if (previewList.length === 0) {
- toast.error('请至少保留 1 个代理后再导入')
- return
- }
- setImporting(true)
- try {
- const sourceURL = importMode === 'clash' ? importResolvedUrl.trim() : ''
- const isURLImport = !!sourceURL
- const sourceNamePrefix = importMode === 'clash' ? importNamePrefix.trim() : ''
- const sourceID = isURLImport ? resolveImportSourceID(proxies, sourceURL, sourceNamePrefix) : ''
- const sourceAutoRefresh = isURLImport ? globalAutoRefreshEnabled : false
- const sourceRefreshIntervalM = sourceAutoRefresh ? globalRefreshInterval : 0
- const sourceLastRefreshAt = isURLImport ? new Date().toISOString() : ''
- const oldSourceProxies = isURLImport
- ? proxies.filter(item => (item.sourceId || '').trim() === sourceID)
- : []
- const pickExistingID = createExistingProxyIDPicker(oldSourceProxies)
-
- const newProxies: BrowserProxy[] = previewList.map((p) => ({
- proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(),
- proxyName: p.proxyName,
- proxyConfig: p.proxyConfig,
- dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
- groupName: p.groupName.trim() || undefined,
- sourceId: sourceID || undefined,
- sourceUrl: sourceURL || undefined,
- sourceNamePrefix: sourceNamePrefix || undefined,
- sourceAutoRefresh,
- sourceRefreshIntervalM,
- sourceLastRefreshAt: sourceLastRefreshAt || undefined,
- }))
- const allProxies = isURLImport
- ? proxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies)
- : [...proxies, ...newProxies]
- await saveProxies(allProxies)
- if (isURLImport && removedPreviewProxyNames.length > 0) {
- appendSourceIgnoredProxyNames(sourceID, removedPreviewProxyNames)
- }
- setPreviewModalOpen(false)
- setImportUrl('')
- setImportResolvedUrl('')
- setImportText('')
- setImportDnsServers('')
- setImportNamePrefix('')
- setImportGroupName('')
- setChainImportText('')
- setDirectImportText('')
- setChainImportForm(createInitialChainImportForm())
- setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
- setPreviewList([])
- setRemovedPreviewProxyNames([])
- toast.success(`成功导入 ${newProxies.length} 个代理`)
- } catch (error: any) {
- toast.error(error?.message || '导入失败')
- } finally {
- setImporting(false)
- }
- }
-
- const selectedCount = selectedIds.size
- const canParseImport = importMode === 'clash'
- ? !!importText.trim()
- : importMode === 'direct'
- ? !!directImportText.trim() || (!!directImportForm.server.trim() && !!directImportForm.port.trim())
- : !!chainImportForm.first.server.trim()
- && !!chainImportForm.first.port.trim()
- && !!chainImportForm.second.server.trim()
- && !!chainImportForm.second.port.trim()
+ const {
+ deleteConfirmOpen,
+ setDeleteConfirmOpen,
+ handleDeleteClick,
+ handleDeleteConfirm,
+ } = useProxyDeleteFlow({ proxies, saveProxies, removeSelectedId })
return (
)
}
+
+
diff --git a/frontend/src/modules/browser/pages/automationScriptDetail/AutomationScriptDetailBodyPanels.tsx b/frontend/src/modules/browser/pages/automationScriptDetail/AutomationScriptDetailBodyPanels.tsx
index 6be81912..2ff7e514 100644
--- a/frontend/src/modules/browser/pages/automationScriptDetail/AutomationScriptDetailBodyPanels.tsx
+++ b/frontend/src/modules/browser/pages/automationScriptDetail/AutomationScriptDetailBodyPanels.tsx
@@ -179,7 +179,7 @@ export function AutomationScriptDetailBodyPanels({
{"code / param / timeoutMs"}}
+ value={{"instance / params / timeoutMs"}}
/>
!isAutomationScriptPublicAPIVariableName(variable.name))
- .map((variable) => variable.name);
- if (invalidVariableNames.length) {
- return `变量名不合法:${invalidVariableNames.join(", ")}`;
- }
-
- const resolvedBody = applyAutomationScriptPublicAPIVariables(
- config.requestBodyText,
- config.variables,
- collectAutomationScriptPublicAPIVariableValues(config),
- );
- if (resolvedBody.missingRequired.length) {
- return `必填变量缺少默认值:${resolvedBody.missingRequired.join(", ")}`;
- }
- if (!isJSONObjectText(resolvedBody.bodyText)) {
- return "替换变量后的请求 Body 必须是 JSON 对象";
- }
- if (!isJSONText(config.responseBodyText)) {
- return "响应示例必须是合法 JSON";
- }
- return "";
-}
-
-export function hasSamePublicAPIConfig(
- left: AutomationScriptPublicAPIConfig,
- right: AutomationScriptPublicAPIConfig,
-): boolean {
- return (
- left.enabled === right.enabled &&
- left.method === right.method &&
- left.path === right.path &&
- left.requestMode === right.requestMode &&
- left.responseMode === right.responseMode &&
- left.timeoutMs === right.timeoutMs &&
- left.requestBodyText === right.requestBodyText &&
- left.responseBodyText === right.responseBodyText &&
- JSON.stringify(left.variables) === JSON.stringify(right.variables)
- );
-}
-
-export function preparePublicAPIConfigForCompare(
- script: AutomationScriptRecord,
- publicAPI: AutomationScriptPublicAPIConfig = script.publicAPI,
-): AutomationScriptPublicAPIConfig {
- return prepareAutomationScriptPublicAPIConfigForSave({
- ...script,
- publicAPI,
- });
-}
-
-export function buildPersistablePublicAPIConfig(
- script: AutomationScriptRecord,
- publicAPI: AutomationScriptPublicAPIConfig = script.publicAPI,
-): AutomationScriptPublicAPIConfig {
- return prepareAutomationScriptPublicAPIConfigForSave({
- ...script,
- publicAPI: resolveAutomationScriptPublicAPIConfig({
- ...script,
- publicAPI,
- }),
- });
-}
-
export function formatTargetSelectorSummary(
selector: AutomationScriptTargetSelector,
profiles: BrowserProfile[],
diff --git a/frontend/src/modules/browser/pages/automationScriptDetail/publicApiConfigHelpers.ts b/frontend/src/modules/browser/pages/automationScriptDetail/publicApiConfigHelpers.ts
new file mode 100644
index 00000000..7a063188
--- /dev/null
+++ b/frontend/src/modules/browser/pages/automationScriptDetail/publicApiConfigHelpers.ts
@@ -0,0 +1,111 @@
+import {
+ applyAutomationScriptPublicAPIVariables,
+ collectAutomationScriptPublicAPIVariableValues,
+ isAutomationScriptPublicAPIVariableName,
+ prepareAutomationScriptPublicAPIConfigForSave,
+ resolveAutomationScriptPublicAPIConfig,
+ type AutomationScriptPublicAPIConfig,
+ type AutomationScriptRecord,
+} from "../../automationScripts";
+
+function isJSONObjectText(value: string): boolean {
+ const normalized = value.trim();
+ if (!normalized) {
+ return true;
+ }
+
+ try {
+ const parsed = JSON.parse(normalized);
+ return Boolean(parsed && typeof parsed === "object" && !Array.isArray(parsed));
+ } catch {
+ return false;
+ }
+}
+
+function isJSONText(value: string): boolean {
+ const normalized = value.trim();
+ if (!normalized) {
+ return true;
+ }
+
+ try {
+ JSON.parse(normalized);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function validatePublicAPIConfig(
+ config: AutomationScriptPublicAPIConfig,
+): string {
+ if (!config.enabled) {
+ return "";
+ }
+ if (!config.path.trim()) {
+ return "已启用对外接口时,Path 不能为空";
+ }
+
+ const invalidVariableNames = config.variables
+ .filter((variable) => !isAutomationScriptPublicAPIVariableName(variable.name))
+ .map((variable) => variable.name);
+ if (invalidVariableNames.length) {
+ return `变量名不合法:${invalidVariableNames.join(", ")}`;
+ }
+
+ const resolvedBody = applyAutomationScriptPublicAPIVariables(
+ config.requestBodyText,
+ config.variables,
+ collectAutomationScriptPublicAPIVariableValues(config),
+ );
+ if (resolvedBody.missingRequired.length) {
+ return `必填变量缺少默认值:${resolvedBody.missingRequired.join(", ")}`;
+ }
+ if (!isJSONObjectText(resolvedBody.bodyText)) {
+ return "替换变量后的请求 Body 必须是 JSON 对象";
+ }
+ if (!isJSONText(config.responseBodyText)) {
+ return "响应示例必须是合法 JSON";
+ }
+ return "";
+}
+
+export function hasSamePublicAPIConfig(
+ left: AutomationScriptPublicAPIConfig,
+ right: AutomationScriptPublicAPIConfig,
+): boolean {
+ return (
+ left.enabled === right.enabled &&
+ left.method === right.method &&
+ left.path === right.path &&
+ left.requestMode === right.requestMode &&
+ left.responseMode === right.responseMode &&
+ left.timeoutMs === right.timeoutMs &&
+ left.requestBodyText === right.requestBodyText &&
+ left.responseBodyText === right.responseBodyText &&
+ JSON.stringify(left.variables) === JSON.stringify(right.variables)
+ );
+}
+
+export function preparePublicAPIConfigForCompare(
+ script: AutomationScriptRecord,
+ publicAPI: AutomationScriptPublicAPIConfig = script.publicAPI,
+): AutomationScriptPublicAPIConfig {
+ return prepareAutomationScriptPublicAPIConfigForSave({
+ ...script,
+ publicAPI,
+ });
+}
+
+export function buildPersistablePublicAPIConfig(
+ script: AutomationScriptRecord,
+ publicAPI: AutomationScriptPublicAPIConfig = script.publicAPI,
+): AutomationScriptPublicAPIConfig {
+ return prepareAutomationScriptPublicAPIConfigForSave({
+ ...script,
+ publicAPI: resolveAutomationScriptPublicAPIConfig({
+ ...script,
+ publicAPI,
+ }),
+ });
+}
diff --git a/frontend/src/modules/browser/pages/browserList/useBrowserListData.ts b/frontend/src/modules/browser/pages/browserList/useBrowserListData.ts
new file mode 100644
index 00000000..0324e7b9
--- /dev/null
+++ b/frontend/src/modules/browser/pages/browserList/useBrowserListData.ts
@@ -0,0 +1,158 @@
+import { useEffect, useRef, useState } from 'react'
+import type { BrowserGroupWithCount, BrowserProfile, BrowserProxy } from '../../types'
+import { fetchBrowserProfiles, fetchBrowserProxies, fetchGroups } from '../../api'
+import { EventsOn } from '../../../../wailsjs/runtime/runtime'
+
+interface UseBrowserListDataOptions {
+ loadQuota: () => void
+ loadCores: () => void
+}
+
+export function useBrowserListData({ loadQuota, loadCores }: UseBrowserListDataOptions) {
+ const [profiles, setProfiles] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [proxies, setProxies] = useState([])
+ const [groups, setGroups] = useState([])
+ const [startingIds, setStartingIds] = useState>(new Set())
+ const [stoppingIds, setStoppingIds] = useState>(new Set())
+ const profilesRef = useRef([])
+ const silentRefreshInFlightRef = useRef(false)
+
+ const updatePendingIds = (
+ setter: React.Dispatch>>,
+ profileId: string,
+ active: boolean
+ ) => {
+ setter(prev => {
+ const next = new Set(prev)
+ if (active) {
+ next.add(profileId)
+ } else {
+ next.delete(profileId)
+ }
+ return next
+ })
+ }
+
+ const replaceProfilesState = (items: BrowserProfile[]) => {
+ profilesRef.current = items
+ setProfiles(items)
+ }
+
+ const updateProfilesState = (updater: (items: BrowserProfile[]) => BrowserProfile[]) => {
+ const next = updater(profilesRef.current)
+ profilesRef.current = next
+ setProfiles(next)
+ }
+
+ const mergeProfileState = (profile: BrowserProfile | null | undefined) => {
+ if (!profile) return
+ updateProfilesState(prev => prev.map(item => (
+ item.profileId === profile.profileId ? { ...item, ...profile } : item
+ )))
+ }
+
+ const syncProfiles = (items: BrowserProfile[], syncRuntimeState: boolean) => {
+ if (syncRuntimeState) {
+ const previousById = new Map(profilesRef.current.map(item => [item.profileId, item]))
+ const newlyRunning = items.find(item => item.running && !previousById.get(item.profileId)?.running)
+ if (newlyRunning) {
+ updatePendingIds(setStartingIds, newlyRunning.profileId, false)
+ updatePendingIds(setStoppingIds, newlyRunning.profileId, false)
+ }
+ items.forEach(item => {
+ if (!item.running && previousById.get(item.profileId)?.running) {
+ updatePendingIds(setStartingIds, item.profileId, false)
+ updatePendingIds(setStoppingIds, item.profileId, false)
+ }
+ })
+ }
+ replaceProfilesState(items)
+ }
+
+ const loadProfiles = async ({ silent = false, syncRuntimeState = false }: { silent?: boolean; syncRuntimeState?: boolean } = {}) => {
+ if (silent && silentRefreshInFlightRef.current) {
+ return profilesRef.current
+ }
+ if (!silent) {
+ setLoading(true)
+ } else {
+ silentRefreshInFlightRef.current = true
+ }
+ try {
+ const items = await fetchBrowserProfiles()
+ syncProfiles(items, syncRuntimeState)
+ return items
+ } finally {
+ if (silent) {
+ silentRefreshInFlightRef.current = false
+ } else {
+ setLoading(false)
+ }
+ }
+ }
+
+ const loadGroups = async () => {
+ setGroups(await fetchGroups())
+ }
+
+ useEffect(() => {
+ void loadProfiles()
+ loadGroups()
+ loadQuota()
+ fetchBrowserProxies().then(setProxies)
+ loadCores()
+
+ const clearPending = (payload: any) => {
+ const profileId = typeof payload === 'string' ? payload : payload?.profileId
+ if (profileId) {
+ updatePendingIds(setStartingIds, profileId, false)
+ updatePendingIds(setStoppingIds, profileId, false)
+ }
+ }
+
+ const offStarted = EventsOn('browser:instance:started', (payload: any) => {
+ clearPending(payload)
+ void loadProfiles({ silent: true, syncRuntimeState: true })
+ })
+ const offUpdated = EventsOn('browser:instance:updated', () => {
+ void loadProfiles({ silent: true, syncRuntimeState: true })
+ })
+ const offStopped = EventsOn('browser:instance:stopped', (payload: any) => {
+ clearPending(payload)
+ void loadProfiles({ silent: true, syncRuntimeState: true })
+ })
+ const offCrashed = EventsOn('browser:instance:crashed', (payload: any) => {
+ clearPending(payload)
+ void loadProfiles({ silent: true, syncRuntimeState: true })
+ })
+
+ const timer = window.setInterval(() => {
+ if (document.visibilityState !== 'visible') return
+ void loadProfiles({ silent: true, syncRuntimeState: true })
+ }, 2000)
+
+ return () => {
+ window.clearInterval(timer)
+ offStarted?.()
+ offUpdated?.()
+ offStopped?.()
+ offCrashed?.()
+ }
+ }, [])
+
+ return {
+ profiles,
+ loading,
+ proxies,
+ groups,
+ startingIds,
+ stoppingIds,
+ setStartingIds,
+ setStoppingIds,
+ updatePendingIds,
+ updateProfilesState,
+ mergeProfileState,
+ loadProfiles,
+ }
+}
diff --git a/frontend/src/modules/browser/pages/browserList/useBrowserListSettings.ts b/frontend/src/modules/browser/pages/browserList/useBrowserListSettings.ts
new file mode 100644
index 00000000..eca0803f
--- /dev/null
+++ b/frontend/src/modules/browser/pages/browserList/useBrowserListSettings.ts
@@ -0,0 +1,215 @@
+import { useState } from 'react'
+import { toast } from '../../../../shared/components'
+import { PROJECT_GITHUB_URL } from '../../../../config/links'
+import { BrowserOpenURL } from '../../../../wailsjs/runtime/runtime'
+import { fetchDashboardStats, redeemCDKey, redeemGithubStar, reloadConfig } from '../../../dashboard/api'
+import type { BrowserCore, BrowserCoreInput, BrowserSettings } from '../../types'
+import {
+ deleteBrowserCore,
+ fetchBrowserCores,
+ fetchBrowserSettings,
+ saveBrowserCore,
+ saveBrowserSettings,
+ setDefaultBrowserCore,
+ validateBrowserCorePath,
+} from '../../api'
+
+const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
+ userDataRoot: 'data',
+ defaultFingerprintArgs: [],
+ defaultLaunchArgs: [],
+ defaultStartUrls: [],
+ lightStartEnabled: true,
+ restoreLastSession: false,
+ startReadyTimeoutMs: 3000,
+ startStableWindowMs: 1200,
+}
+
+export function useBrowserListSettings() {
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false)
+ const [settings, setSettings] = useState(DEFAULT_BROWSER_SETTINGS)
+ const [fingerprintText, setFingerprintText] = useState('')
+ const [launchText, setLaunchText] = useState('')
+ const [startUrlsText, setStartUrlsText] = useState('')
+ const [savingSettings, setSavingSettings] = useState(false)
+
+ const [cores, setCores] = useState([])
+ const [coreModalOpen, setCoreModalOpen] = useState(false)
+ const [coreForm, setCoreForm] = useState({ coreId: '', coreName: '', corePath: '', isDefault: false })
+ const [coreValidation, setCoreValidation] = useState<{ valid: boolean; message: string } | null>(null)
+ const [savingCore, setSavingCore] = useState(false)
+
+ const [expandModalOpen, setExpandModalOpen] = useState(false)
+ const [cdKey, setCdKey] = useState('')
+ const [redeeming, setRedeeming] = useState(false)
+ const [maxProfileLimit, setMaxProfileLimit] = useState(20)
+
+ const loadSettings = async () => {
+ const data = await fetchBrowserSettings()
+ setSettings(data)
+ setFingerprintText((data.defaultFingerprintArgs || []).join('\n'))
+ setLaunchText((data.defaultLaunchArgs || []).join('\n'))
+ setStartUrlsText((data.defaultStartUrls || []).join('\n'))
+ }
+
+ const loadCores = async () => {
+ setCores(await fetchBrowserCores())
+ }
+
+ const loadQuota = async () => {
+ try {
+ await reloadConfig()
+ const stats = await fetchDashboardStats()
+ setMaxProfileLimit(stats.maxProfileLimit || 20)
+ } catch {
+ // ignore
+ }
+ }
+
+ const handleOpenSettings = async () => {
+ await Promise.all([loadSettings(), loadCores()])
+ setSettingsModalOpen(true)
+ }
+
+ const handleSaveSettings = async () => {
+ setSavingSettings(true)
+ try {
+ await saveBrowserSettings({
+ ...settings,
+ defaultFingerprintArgs: fingerprintText.split('\n').map(s => s.trim()).filter(Boolean),
+ defaultLaunchArgs: launchText.split('\n').map(s => s.trim()).filter(Boolean),
+ defaultStartUrls: startUrlsText.split('\n').map(s => s.trim()).filter(Boolean),
+ })
+ toast.success('配置已保存')
+ setSettingsModalOpen(false)
+ } catch (error: any) {
+ toast.error(error?.message || '保存失败')
+ } finally {
+ setSavingSettings(false)
+ }
+ }
+
+ const handleOpenCoreModal = (core?: BrowserCore) => {
+ setCoreForm(core ? { ...core } : { coreId: '', coreName: '', corePath: '', isDefault: false })
+ setCoreValidation(null)
+ setCoreModalOpen(true)
+ }
+
+ const handleValidateCorePath = async () => {
+ if (!coreForm.corePath.trim()) {
+ setCoreValidation({ valid: false, message: '请输入路径' })
+ return
+ }
+ const result = await validateBrowserCorePath(coreForm.corePath)
+ setCoreValidation(result)
+ }
+
+ const handleSaveCore = async () => {
+ if (!coreForm.coreName.trim()) {
+ toast.error('请输入内核名称')
+ return
+ }
+ if (!coreForm.corePath.trim()) {
+ toast.error('请输入内核路径')
+ return
+ }
+ setSavingCore(true)
+ try {
+ await saveBrowserCore(coreForm)
+ toast.success('内核已保存')
+ setCoreModalOpen(false)
+ loadCores()
+ } catch (error: any) {
+ toast.error(error?.message || '保存失败')
+ } finally {
+ setSavingCore(false)
+ }
+ }
+
+ const handleDeleteCore = async (coreId: string) => {
+ if (cores.length <= 1) {
+ toast.error('至少保留一个内核')
+ return
+ }
+ await deleteBrowserCore(coreId)
+ toast.success('内核已删除')
+ loadCores()
+ }
+
+ const handleSetDefaultCore = async (coreId: string) => {
+ await setDefaultBrowserCore(coreId)
+ toast.success('已设为默认')
+ loadCores()
+ }
+
+ const handleRedeem = async () => {
+ if (!cdKey.trim()) return
+ setRedeeming(true)
+ const result = await redeemCDKey(cdKey.trim())
+ setRedeeming(false)
+ if (result.success) {
+ toast.success('兑换成功!此名额已到账')
+ setCdKey('')
+ loadQuota()
+ } else {
+ toast.error(result.message || '兑换失败')
+ }
+ }
+
+ const handleClaimStarGift = async () => {
+ setRedeeming(true)
+ const starRes = await redeemGithubStar()
+ setRedeeming(false)
+ if (starRes.success) {
+ toast.success('感谢您的支持!已额外赠送 50 个永久额度!')
+ setCdKey('')
+ loadQuota()
+ } else {
+ toast.error(starRes.message || '领取失败')
+ }
+ }
+
+ const handleOpenGithubStarGift = async () => {
+ BrowserOpenURL(PROJECT_GITHUB_URL)
+ await handleClaimStarGift()
+ }
+
+ return {
+ settingsModalOpen,
+ setSettingsModalOpen,
+ settings,
+ setSettings,
+ fingerprintText,
+ setFingerprintText,
+ launchText,
+ setLaunchText,
+ startUrlsText,
+ setStartUrlsText,
+ savingSettings,
+ cores,
+ coreModalOpen,
+ setCoreModalOpen,
+ coreForm,
+ setCoreForm,
+ coreValidation,
+ setCoreValidation,
+ savingCore,
+ expandModalOpen,
+ setExpandModalOpen,
+ cdKey,
+ setCdKey,
+ redeeming,
+ maxProfileLimit,
+ loadCores,
+ loadQuota,
+ handleOpenSettings,
+ handleSaveSettings,
+ handleOpenCoreModal,
+ handleValidateCorePath,
+ handleSaveCore,
+ handleDeleteCore,
+ handleSetDefaultCore,
+ handleRedeem,
+ handleOpenGithubStarGift,
+ }
+}
diff --git a/frontend/src/modules/browser/pages/browserList/useBrowserListViewState.ts b/frontend/src/modules/browser/pages/browserList/useBrowserListViewState.ts
new file mode 100644
index 00000000..25228111
--- /dev/null
+++ b/frontend/src/modules/browser/pages/browserList/useBrowserListViewState.ts
@@ -0,0 +1,165 @@
+import { useEffect, useMemo, useState } from 'react'
+import type { BrowserCore, BrowserProfile } from '../../types'
+import { EMPTY_FILTERS, type InstanceFilters } from '../../components/InstanceFilterBar'
+import type { BrowserViewMode } from '../../components/BrowserListLayout'
+
+export const resolveProfileStatus = (running: boolean, debugReady: boolean, starting: boolean, stopping: boolean) => {
+ if (starting) {
+ return { variant: 'info' as const, label: '启动中' }
+ }
+ if (stopping) {
+ return { variant: 'default' as const, label: '停止中' }
+ }
+ if (running && !debugReady) {
+ return { variant: 'info' as const, label: '运行中(待就绪)' }
+ }
+ if (running) {
+ return { variant: 'success' as const, label: '运行中' }
+ }
+ return { variant: 'warning' as const, label: '已停止' }
+}
+
+export function useBrowserListViewState() {
+ const [viewMode, setViewMode] = useState(() => {
+ return (localStorage.getItem('browser:viewMode') as BrowserViewMode) || 'table'
+ })
+ const [filters, setFilters] = useState(() => {
+ try {
+ const saved = localStorage.getItem('browser:filters')
+ if (saved) {
+ const parsed = JSON.parse(saved)
+ return { ...EMPTY_FILTERS, ...parsed, tags: new Set(parsed.tags || []) }
+ }
+ } catch { /* ignore */ }
+ return EMPTY_FILTERS
+ })
+ const [headerCollapsed, setHeaderCollapsed] = useState(() => {
+ return localStorage.getItem('browser:headerCollapsed') === 'true'
+ })
+
+ useEffect(() => {
+ const serializable = { ...filters, tags: Array.from(filters.tags) }
+ localStorage.setItem('browser:filters', JSON.stringify(serializable))
+ }, [filters])
+
+ useEffect(() => {
+ localStorage.setItem('browser:viewMode', viewMode)
+ }, [viewMode])
+
+ useEffect(() => {
+ localStorage.setItem('browser:headerCollapsed', String(headerCollapsed))
+ }, [headerCollapsed])
+
+ return {
+ viewMode,
+ setViewMode,
+ filters,
+ setFilters,
+ headerCollapsed,
+ setHeaderCollapsed,
+ }
+}
+
+export function useBrowserListDerived(
+ profiles: BrowserProfile[],
+ cores: BrowserCore[],
+ filters: InstanceFilters,
+ startingIds: Set,
+ stoppingIds: Set
+) {
+ const runningCount = useMemo(() => profiles.filter(profile => profile.running).length, [profiles])
+ const allTags = useMemo(() => {
+ const set = new Set()
+ profiles.forEach(profile => profile.tags?.forEach(tag => set.add(tag)))
+ return Array.from(set).sort()
+ }, [profiles])
+
+ const defaultCore = useMemo(() => {
+ return cores.find(core => core.isDefault) || cores[0] || null
+ }, [cores])
+
+ const resolveProfileCore = (profile: BrowserProfile) => {
+ const coreId = (profile.coreId || '').trim()
+ if (coreId && !/^default$/i.test(coreId)) {
+ return cores.find(core => core.coreId === coreId) || null
+ }
+ return defaultCore
+ }
+
+ const getProfileCoreLabel = (profile: BrowserProfile) => {
+ const resolvedCore = resolveProfileCore(profile)
+ if (resolvedCore) {
+ return resolvedCore.coreName
+ }
+
+ const coreId = (profile.coreId || '').trim()
+ if (!coreId || /^default$/i.test(coreId)) {
+ return '使用默认内核'
+ }
+ return coreId
+ }
+
+ const isProfileStarting = (profileId: string) => startingIds.has(profileId)
+ const isProfileStopping = (profileId: string) => stoppingIds.has(profileId)
+ const isProfileBusy = (profileId: string) => isProfileStarting(profileId) || isProfileStopping(profileId)
+
+ const getProfileStatus = (profile: BrowserProfile) => (
+ resolveProfileStatus(profile.running, profile.debugReady, isProfileStarting(profile.profileId), isProfileStopping(profile.profileId))
+ )
+
+ const filteredProfiles = useMemo(() => {
+ return profiles.filter(profile => {
+ if (filters.groupId === '__ungrouped__' && profile.groupId) return false
+ if (filters.groupId && filters.groupId !== '__ungrouped__' && profile.groupId !== filters.groupId) return false
+ if (filters.keyword && !profile.profileName.toLowerCase().includes(filters.keyword.toLowerCase())) return false
+ if (filters.status === 'running' && !profile.running) return false
+ if (filters.status === 'stopped' && profile.running) return false
+ if (filters.proxyId === '__none__' && (profile.proxyId || profile.proxyConfig)) return false
+ if (filters.proxyId && filters.proxyId !== '__none__' && profile.proxyId !== filters.proxyId) return false
+ if (filters.coreId) {
+ const effectiveCore = resolveProfileCore(profile)
+ if (!effectiveCore || effectiveCore.coreId !== filters.coreId) return false
+ }
+ if (filters.tags.size > 0 && !profile.tags?.some(tag => filters.tags.has(tag))) return false
+ if (filters.kwSearch) {
+ const query = filters.kwSearch.toLowerCase()
+ const hit = profile.keywords?.some(value => value.toLowerCase().includes(query))
+ if (!hit) return false
+ }
+ return true
+ }).sort((a, b) => naturalCompare(a.profileName, b.profileName))
+ }, [profiles, filters, defaultCore, cores])
+
+ return {
+ runningCount,
+ allTags,
+ filteredProfiles,
+ resolveProfileCore,
+ getProfileCoreLabel,
+ isProfileStarting,
+ isProfileStopping,
+ isProfileBusy,
+ getProfileStatus,
+ }
+}
+
+function naturalCompare(a: string, b: string): number {
+ const re = /(\d+)|(\D+)/g
+ const partsA = a.match(re) || []
+ const partsB = b.match(re) || []
+ for (let index = 0; index < Math.max(partsA.length, partsB.length); index++) {
+ if (index >= partsA.length) return -1
+ if (index >= partsB.length) return 1
+ const partA = partsA[index]
+ const partB = partsB[index]
+ const numberA = Number(partA)
+ const numberB = Number(partB)
+ if (!Number.isNaN(numberA) && !Number.isNaN(numberB)) {
+ if (numberA !== numberB) return numberA - numberB
+ } else {
+ const compared = partA.localeCompare(partB, 'zh-CN')
+ if (compared !== 0) return compared
+ }
+ }
+ return 0
+}
diff --git a/frontend/src/modules/browser/pages/browserList/useBrowserProfileActions.ts b/frontend/src/modules/browser/pages/browserList/useBrowserProfileActions.ts
new file mode 100644
index 00000000..4c7f75f0
--- /dev/null
+++ b/frontend/src/modules/browser/pages/browserList/useBrowserProfileActions.ts
@@ -0,0 +1,154 @@
+import type { Dispatch, SetStateAction } from 'react'
+import { toast } from '../../../../shared/components'
+import {
+ deleteBrowserProfile,
+ restartBrowserInstance,
+ startBrowserInstance,
+ startBrowserInstanceDirect,
+ stopBrowserInstance,
+ validateProxyConfig,
+} from '../../api'
+import type { BrowserProfile } from '../../types'
+import { resolveActionErrorMessage, resolveActionFeedback } from '../../utils/actionErrors'
+
+interface UseBrowserProfileActionsOptions {
+ profiles: BrowserProfile[]
+ setProxyErrorModal: (open: boolean) => void
+ setProxyErrorMsg: (message: string) => void
+ setPendingStartId: (profileId: string | null) => void
+ setOpError: (message: string) => void
+ setStartingIds: Dispatch>>
+ setStoppingIds: Dispatch>>
+ updatePendingIds: (
+ setter: Dispatch>>,
+ profileId: string,
+ active: boolean,
+ ) => void
+ mergeProfileState: (profile: BrowserProfile | null | undefined) => void
+ loadProfiles: (options?: { silent?: boolean; syncRuntimeState?: boolean }) => Promise