From 4a696713cfb92e463093c39b7ab437cd2386366a Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Tue, 23 Jun 2026 23:40:05 +0800 Subject: [PATCH] feat: add profile import export package --- CHANGELOG.md | 24 + README.md | 13 + backend/app_profile_package_api.go | 536 ++++++++++++++++++ frontend/src/modules/browser/api/profiles.ts | 35 +- .../browser/components/BrowserListLayout.tsx | 9 +- .../browser/components/BrowserListWidgets.tsx | 15 +- .../components/BrowserProfilesPanel.tsx | 17 +- .../browser/components/KeywordsExpandRow.tsx | 8 +- .../modules/browser/pages/BrowserListPage.tsx | 64 +++ .../pages/launchApiDocs/contentChangelog.ts | 28 +- .../pages/launchApiDocs/contentIntro.ts | 15 + frontend/src/modules/browser/types.ts | 15 + frontend/src/wailsjs/go/main/App.d.ts | 6 +- frontend/src/wailsjs/go/main/App.js | 8 + 14 files changed, 766 insertions(+), 27 deletions(-) create mode 100644 backend/app_profile_package_api.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 66dcb55a..3e1442ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.3.0 - 2026-06-23 + +- 自动化增强:完善自动化脚本能力,支持更完整的脚本导入、运行、目标实例选择和执行记录管理。 +- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置。 +- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力。 +- 实例迁移:支持浏览器实例导入导出,可将实例配置和用户数据打包迁移到新环境。 +- 数据保留:实例导出包包含完整浏览器用户数据目录,可保留 Cookie、LocalStorage、IndexedDB、扩展本地数据等浏览器状态。 +- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理。 +- 批量操作:实例列表支持批量导出、批量启动、批量停止、批量删除等操作,提升多实例管理效率。 +- 单例操作:单个实例的“更多”菜单新增导出入口,便于快速迁移单个实例。 +- 分组管理:完善实例分组、标签、关键字等管理能力,方便按业务场景组织实例。 +- 文档中心:补充启动 API、自动化、插件管理、代理配置等说明,降低接入和排查成本。 +- 状态检测:增强实例运行状态、调试端口、代理健康、测速结果等状态反馈。 +- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息。 + +## 1.2.0 - 2026-05-09 + +- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力。 +- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回。 +- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入。 +- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测。 +- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点。 +- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例。 + ## 1.1.0 - 2026-03-19 - 完善 Linux 支持:补齐 Linux 环境下的开发、打包、安装、启动与运行链路,并持续修复安装版启动与退出稳定性问题。 diff --git a/README.md b/README.md index c1c77b70..492f7a02 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ Ant Browser 适合以下场景: ## 近期更新 +### 1.3.0 · 2026-06-23 + +- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理,提升多实例自动化编排能力 +- 插件管理:新增插件包管理能力,支持插件安装、导入、启停、删除、实例限制和单实例插件配置 +- VPN 优化:优化代理/VPN 连接链路,完善 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力 +- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境 +- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理 +- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口,减少页面拥挤和无效信息 + ### 1.2.0 · 2026-05-09 - 重点升级接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口,方便外部系统直接调用浏览器能力 @@ -83,6 +92,10 @@ Ant Browser 适合以下场景: - 内核管理:支持维护多个 Chrome 内核版本,并设置默认内核 - 快捷启动:支持通过实例 Code 和 `Ctrl + K` 快速打开目标实例 - 标签与检索:支持按标签、关键字、状态、代理、内核、分组进行筛选 +- 自动化脚本:支持脚本导入、运行、目标实例选择、执行记录和外部接口调用 +- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置 +- 实例迁移:支持将实例配置和浏览器用户数据目录导出为 ZIP,并导入为新实例 +- VPN / 代理检测:支持连接栈预热、测速、IP 健康检测和代理异常处理 - 本地化存储:配置和实例数据保存在本地,适合长期使用和备份 ## 界面预览 diff --git a/backend/app_profile_package_api.go b/backend/app_profile_package_api.go new file mode 100644 index 00000000..eabeabcd --- /dev/null +++ b/backend/app_profile_package_api.go @@ -0,0 +1,536 @@ +package backend + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "ant-chrome/backend/internal/browser" + + "github.com/google/uuid" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const profilePackageFormat = "ant-chrome-profile-package" + +type ProfilePackageManifest struct { + Format string `json:"format"` + Version int `json:"version"` + ExportedAt string `json:"exportedAt"` + ProfileCount int `json:"profileCount"` +} + +type ProfilePackageExportResult struct { + Cancelled bool `json:"cancelled"` + ZipPath string `json:"zipPath"` + ProfileCount int `json:"profileCount"` + FileCount int `json:"fileCount"` + Message string `json:"message"` +} + +type ProfilePackageImportResult struct { + Cancelled bool `json:"cancelled"` + ImportedCount int `json:"importedCount"` + ProfileMappings map[string]string `json:"profileMappings"` + Message string `json:"message"` +} + +// BrowserProfilePackageExport 导出选中的实例配置和浏览器用户数据目录。 +func (a *App) BrowserProfilePackageExport(profileIds []string) (ProfilePackageExportResult, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + ids := normalizeProfilePackageIDs(profileIds) + if len(ids) == 0 { + return ProfilePackageExportResult{}, fmt.Errorf("请选择要导出的实例") + } + if a.ctx == nil { + return ProfilePackageExportResult{}, fmt.Errorf("应用上下文未初始化") + } + + profiles, err := a.collectProfilesForPackage(ids) + if err != nil { + return ProfilePackageExportResult{}, err + } + + defaultName := fmt.Sprintf("ant-chrome-profile-package-%s.zip", time.Now().Format("20060102-150405")) + savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ + Title: "导出实例", + DefaultFilename: defaultName, + Filters: []wailsruntime.FileFilter{ + {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, + }, + }) + if err != nil { + return ProfilePackageExportResult{}, fmt.Errorf("打开保存对话框失败: %w", err) + } + if strings.TrimSpace(savePath) == "" { + return ProfilePackageExportResult{Cancelled: true, Message: "已取消导出"}, nil + } + savePath = ensureZipSuffix(savePath) + + fileCount, err := a.writeProfilePackage(savePath, profiles) + if err != nil { + return ProfilePackageExportResult{}, err + } + return ProfilePackageExportResult{ + Cancelled: false, + ZipPath: savePath, + ProfileCount: len(profiles), + FileCount: fileCount, + Message: "导出完成", + }, nil +} + +// BrowserProfilePackageImport 导入实例包,冲突时始终生成新实例和新目录。 +func (a *App) BrowserProfilePackageImport() (ProfilePackageImportResult, error) { + a.maintenanceMu.Lock() + defer a.maintenanceMu.Unlock() + + if a.ctx == nil { + return ProfilePackageImportResult{}, fmt.Errorf("应用上下文未初始化") + } + zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "导入实例", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, + }, + }) + if err != nil { + return ProfilePackageImportResult{}, fmt.Errorf("打开文件对话框失败: %w", err) + } + if strings.TrimSpace(zipPath) == "" { + return ProfilePackageImportResult{Cancelled: true, Message: "已取消导入"}, nil + } + return a.importProfilePackageFromPath(zipPath) +} + +func (a *App) collectProfilesForPackage(profileIds []string) ([]browser.Profile, error) { + a.browserMgr.InitData() + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profiles := make([]browser.Profile, 0, len(profileIds)) + missing := make([]string, 0) + running := make([]string, 0) + for _, id := range profileIds { + profile := a.browserMgr.Profiles[id] + if profile == nil { + missing = append(missing, id) + continue + } + if profile.Running { + running = append(running, profile.ProfileName) + continue + } + copyProfile := *profile + copyProfile.LaunchCode = "" + copyProfile.Running = false + copyProfile.DebugPort = 0 + copyProfile.DebugReady = false + copyProfile.Pid = 0 + copyProfile.RuntimeWarning = "" + copyProfile.LastError = "" + a.prepareProfileProxyForPackage(©Profile) + profiles = append(profiles, copyProfile) + } + if len(missing) > 0 { + return nil, fmt.Errorf("实例不存在: %s", strings.Join(missing, ", ")) + } + if len(running) > 0 { + return nil, fmt.Errorf("请先停止实例再导出: %s", strings.Join(running, ", ")) + } + return profiles, nil +} + +func (a *App) writeProfilePackage(zipPath string, profiles []browser.Profile) (int, error) { + if err := os.MkdirAll(filepath.Dir(zipPath), 0o755); err != nil { + return 0, fmt.Errorf("创建导出目录失败: %w", err) + } + tmpPath := zipPath + ".tmp" + _ = os.Remove(tmpPath) + out, err := os.Create(tmpPath) + if err != nil { + return 0, fmt.Errorf("创建导出文件失败: %w", err) + } + zipWriter := zip.NewWriter(out) + fileCount := 0 + + writeErr := func() error { + manifest := ProfilePackageManifest{ + Format: profilePackageFormat, + Version: 1, + ExportedAt: time.Now().Format(time.RFC3339), + ProfileCount: len(profiles), + } + if err := writeProfilePackageJSON(zipWriter, "manifest.json", manifest); err != nil { + return err + } + fileCount++ + if err := writeProfilePackageJSON(zipWriter, "profiles.json", profiles); err != nil { + return err + } + fileCount++ + for i := range profiles { + profile := &profiles[i] + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if _, err := os.Stat(userDataDir); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("读取用户数据目录失败: %w", err) + } + added, err := writeProfilePackageDir(zipWriter, userDataDir, "user-data/"+profile.ProfileId) + if err != nil { + return fmt.Errorf("打包用户数据失败 [%s]: %w", profile.ProfileName, err) + } + fileCount += added + } + return nil + }() + + closeZipErr := zipWriter.Close() + closeFileErr := out.Close() + if writeErr != nil { + _ = os.Remove(tmpPath) + return 0, writeErr + } + if closeZipErr != nil { + _ = os.Remove(tmpPath) + return 0, closeZipErr + } + if closeFileErr != nil { + _ = os.Remove(tmpPath) + return 0, closeFileErr + } + if err := os.Rename(tmpPath, zipPath); err != nil { + _ = os.Remove(tmpPath) + return 0, fmt.Errorf("保存导出文件失败: %w", err) + } + return fileCount, nil +} + +func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImportResult, error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return ProfilePackageImportResult{}, fmt.Errorf("打开实例包失败: %w", err) + } + defer reader.Close() + + var manifest ProfilePackageManifest + if err := readProfilePackageJSON(reader.File, "manifest.json", &manifest); err != nil { + return ProfilePackageImportResult{}, err + } + if manifest.Format != profilePackageFormat || manifest.Version != 1 { + return ProfilePackageImportResult{}, fmt.Errorf("不支持的实例包格式") + } + var profiles []browser.Profile + if err := readProfilePackageJSON(reader.File, "profiles.json", &profiles); err != nil { + return ProfilePackageImportResult{}, err + } + if len(profiles) == 0 { + return ProfilePackageImportResult{}, fmt.Errorf("实例包为空") + } + + a.browserMgr.InitData() + if a.config.App.MaxProfileLimit > 0 { + a.browserMgr.Mutex.Lock() + currentCount := len(a.browserMgr.Profiles) + a.browserMgr.Mutex.Unlock() + if currentCount+len(profiles) > a.config.App.MaxProfileLimit { + return ProfilePackageImportResult{}, fmt.Errorf("实例数量已达上限 (%d个),无法导入 %d 个实例", a.config.App.MaxProfileLimit, len(profiles)) + } + } + now := time.Now().Format(time.RFC3339) + mappings := make(map[string]string, len(profiles)) + prepared := make([]browser.Profile, 0, len(profiles)) + for _, source := range profiles { + oldID := strings.TrimSpace(source.ProfileId) + if oldID == "" { + oldID = uuid.NewString() + } + newID := uuid.NewString() + source.ProfileId = newID + source.ProfileName = buildImportedProfileName(source.ProfileName) + source.UserDataDir = newID + source.Running = false + source.DebugPort = 0 + source.DebugReady = false + source.Pid = 0 + source.RuntimeWarning = "" + source.LastError = "" + source.LaunchCode = "" + source.CreatedAt = now + source.UpdatedAt = now + source.DeletedAt = "" + a.applyImportedProfileProxyByName(&source) + prepared = append(prepared, source) + mappings[oldID] = newID + } + + for _, profile := range prepared { + if err := a.extractProfileUserData(reader.File, mappings, profile.ProfileId); err != nil { + return ProfilePackageImportResult{}, err + } + } + a.browserMgr.Mutex.Lock() + for i := range prepared { + profile := &prepared[i] + a.browserMgr.Profiles[profile.ProfileId] = profile + if a.launchCodeSvc != nil { + if code, err := a.launchCodeSvc.EnsureCode(profile.ProfileId); err == nil { + profile.LaunchCode = code + } + } + } + a.browserMgr.Mutex.Unlock() + if err := a.browserMgr.SaveProfiles(); err != nil { + return ProfilePackageImportResult{}, err + } + + return ProfilePackageImportResult{ + Cancelled: false, + ImportedCount: len(prepared), + ProfileMappings: mappings, + Message: "导入完成", + }, nil +} + +func (a *App) extractProfileUserData(files []*zip.File, mappings map[string]string, newProfileID string) error { + oldProfileID := "" + for oldID, mappedID := range mappings { + if mappedID == newProfileID { + oldProfileID = oldID + break + } + } + if oldProfileID == "" { + return fmt.Errorf("实例映射不存在: %s", newProfileID) + } + profile := &browser.Profile{ProfileId: newProfileID, UserDataDir: newProfileID} + destDir := a.browserMgr.ResolveUserDataDir(profile) + if err := os.RemoveAll(destDir); err != nil { + return fmt.Errorf("清理用户数据目录失败: %w", err) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("创建用户数据目录失败: %w", err) + } + prefix := "user-data/" + oldProfileID + "/" + for _, file := range files { + name := filepath.ToSlash(file.Name) + if !strings.HasPrefix(name, prefix) { + continue + } + rel := strings.TrimPrefix(name, prefix) + if rel == "" { + continue + } + if err := extractProfilePackageFile(file, destDir, rel); err != nil { + return err + } + } + return nil +} + +func writeProfilePackageJSON(zipWriter *zip.Writer, name string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + writer, err := zipWriter.Create(name) + if err != nil { + return err + } + _, err = writer.Write(data) + return err +} + +func writeProfilePackageDir(zipWriter *zip.Writer, srcDir string, destPrefix string) (int, error) { + count := 0 + err := filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + zipName := filepath.ToSlash(filepath.Join(destPrefix, rel)) + if entry.IsDir() { + _, err := zipWriter.Create(strings.TrimSuffix(zipName, "/") + "/") + return err + } + writer, err := zipWriter.Create(zipName) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(writer, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + count++ + return nil + }) + return count, err +} + +func readProfilePackageJSON(files []*zip.File, name string, target any) error { + for _, file := range files { + if filepath.ToSlash(file.Name) != name { + continue + } + reader, err := file.Open() + if err != nil { + return err + } + defer reader.Close() + return json.NewDecoder(reader).Decode(target) + } + return fmt.Errorf("实例包缺少 %s", name) +} + +func extractProfilePackageFile(file *zip.File, destDir string, rel string) error { + cleanRel := filepath.Clean(filepath.FromSlash(rel)) + if cleanRel == "." || strings.HasPrefix(cleanRel, "..") || filepath.IsAbs(cleanRel) { + return fmt.Errorf("非法路径: %s", rel) + } + target := filepath.Join(destDir, cleanRel) + cleanDest := filepath.Clean(destDir) + cleanTarget := filepath.Clean(target) + if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) { + return fmt.Errorf("非法路径: %s", rel) + } + if file.FileInfo().IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + reader, err := file.Open() + if err != nil { + return err + } + defer reader.Close() + out, err := os.Create(target) + if err != nil { + return err + } + _, copyErr := io.Copy(out, reader) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func normalizeProfilePackageIDs(ids []string) []string { + seen := make(map[string]struct{}, len(ids)) + result := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + sort.Strings(result) + return result +} + +func ensureZipSuffix(path string) string { + if strings.EqualFold(filepath.Ext(path), ".zip") { + return path + } + return path + ".zip" +} + +func buildImportedProfileName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + name = "导入实例" + } + return name + "(导入)" +} + +func (a *App) prepareProfileProxyForPackage(profile *browser.Profile) { + if profile == nil { + return + } + proxyName := strings.TrimSpace(profile.ProxyBindName) + if proxyName == "" { + if proxy, ok := a.browserMgr.GetProxyByID(profile.ProxyId); ok { + proxyName = strings.TrimSpace(proxy.ProxyName) + } + } + profile.ProxyId = "" + profile.ProxyConfig = "" + profile.ProxyBindSourceID = "" + profile.ProxyBindSourceURL = "" + profile.ProxyBindName = proxyName + profile.ProxyBindUpdatedAt = "" +} + +func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) { + if profile == nil { + return + } + proxyName := strings.TrimSpace(profile.ProxyBindName) + profile.ProxyId = "" + profile.ProxyConfig = "" + profile.ProxyBindSourceID = "" + profile.ProxyBindSourceURL = "" + profile.ProxyBindUpdatedAt = "" + if proxyName == "" { + profile.ProxyBindName = "" + return + } + if proxy, ok := a.findUniqueProxyByName(proxyName); ok { + browser.BindProfileToProxy(profile, proxy, true) + return + } + profile.ProxyBindName = "" +} + +func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) { + target := strings.ToLower(strings.TrimSpace(proxyName)) + if target == "" { + return browser.Proxy{}, false + } + proxies := browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies) + var hit browser.Proxy + matched := 0 + for _, proxy := range proxies { + if strings.ToLower(strings.TrimSpace(proxy.ProxyName)) != target { + continue + } + hit = proxy + matched++ + if matched > 1 { + return browser.Proxy{}, false + } + } + return hit, matched == 1 +} diff --git a/frontend/src/modules/browser/api/profiles.ts b/frontend/src/modules/browser/api/profiles.ts index d361ddb6..4e81909f 100644 --- a/frontend/src/modules/browser/api/profiles.ts +++ b/frontend/src/modules/browser/api/profiles.ts @@ -1,6 +1,12 @@ import { applyBrowserProfileCopyOptionsToArgs, createBrowserProfileCopyOptions } from '../copyOptions' import { buildBrowserProfileCopyName } from '../copyName' -import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProfileInput } from '../types' +import type { + BrowserProfile, + BrowserProfileCopyOptions, + BrowserProfileInput, + BrowserProfilePackageExportResult, + BrowserProfilePackageImportResult, +} from '../types' import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime' export async function fetchBrowserProfiles(): Promise { @@ -38,6 +44,33 @@ export async function fetchAllTags(): Promise { return Array.from(tags).sort() } +export async function exportBrowserProfilePackage(profileIds: string[]): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfilePackageExport) { + return await bindings.BrowserProfilePackageExport(profileIds) + } + return { + cancelled: true, + zipPath: '', + profileCount: 0, + fileCount: 0, + message: '当前环境不支持导出实例', + } +} + +export async function importBrowserProfilePackage(): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProfilePackageImport) { + return await bindings.BrowserProfilePackageImport() + } + return { + cancelled: true, + importedCount: 0, + profileMappings: {}, + message: '当前环境不支持导入实例', + } +} + export async function createBrowserProfile(input: BrowserProfileInput): Promise { const bindings: any = await getBindings() if (bindings?.BrowserProfileCreate) { diff --git a/frontend/src/modules/browser/components/BrowserListLayout.tsx b/frontend/src/modules/browser/components/BrowserListLayout.tsx index 66eb44e5..ed7f7bbc 100644 --- a/frontend/src/modules/browser/components/BrowserListLayout.tsx +++ b/frontend/src/modules/browser/components/BrowserListLayout.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom' -import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, XCircle } from 'lucide-react' +import { Activity, CheckCircle, ChevronRight, ChevronUp, Edit2, FileText, Gift, LayoutGrid, List, Play, Plus, RefreshCw, Sliders, Square, Star, Trash2, Upload, XCircle } from 'lucide-react' import { Button, Card, FormItem, Input, Modal, StatCard, Switch, Table, Textarea } from '../../../shared/components' import type { TableColumn } from '../../../shared/components/Table' @@ -27,6 +27,8 @@ interface BrowserListHeaderProps { onOpenSettings: () => void onOpenExpandModal: () => void onOpenTrash: () => void + onImportProfiles: () => void + importingProfiles?: boolean onViewModeChange: (next: BrowserViewMode) => void } @@ -47,6 +49,8 @@ export function BrowserListHeader({ onOpenSettings, onOpenExpandModal, onOpenTrash, + onImportProfiles, + importingProfiles = false, onViewModeChange, }: BrowserListHeaderProps) { return ( @@ -75,6 +79,9 @@ export function BrowserListHeader({ + + + , document.body )} @@ -400,6 +411,7 @@ export function BrowserProfilesPanel({ onRestart, onOpenKeywords, onOpenExtensions, + onExport, onOpenCopy, onOpenProxyPicker, onDelete, @@ -528,6 +540,7 @@ export function BrowserProfilesPanel({ onRestart={() => onRestart(record.profileId)} onOpenKeywords={() => onOpenKeywords(record)} onOpenExtensions={() => onOpenExtensions(record)} + onExport={() => onExport(record)} /> diff --git a/frontend/src/modules/browser/components/KeywordsExpandRow.tsx b/frontend/src/modules/browser/components/KeywordsExpandRow.tsx index 54e4ccf1..17d5db69 100644 --- a/frontend/src/modules/browser/components/KeywordsExpandRow.tsx +++ b/frontend/src/modules/browser/components/KeywordsExpandRow.tsx @@ -27,17 +27,17 @@ export function KeywordsExpandRow({ keywords, colSpan }: Props) { {!keywords?.length ? ( - ) : ( -
+
{keywords.map((kw, i) => ( {i + 1}. diff --git a/frontend/src/modules/browser/pages/BrowserListPage.tsx b/frontend/src/modules/browser/pages/BrowserListPage.tsx index 6f5cf56a..60c12504 100644 --- a/frontend/src/modules/browser/pages/BrowserListPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserListPage.tsx @@ -18,7 +18,9 @@ import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup' import { copyBrowserProfile, deleteBrowserProfile, + exportBrowserProfilePackage, fetchBrowserProfileTrash, + importBrowserProfilePackage, permanentlyDeleteBrowserProfile, restoreBrowserProfile, startBrowserInstance, @@ -40,6 +42,7 @@ export function BrowserListPage() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [batchLoading, setBatchLoading] = useState(false) + const [profilePackageBusy, setProfilePackageBusy] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean mode: 'single' | 'batch' @@ -259,6 +262,62 @@ export function BrowserListPage() { loadProfiles() } + const handleBatchExport = async () => { + const ids = Array.from(selectedIds) + if (ids.length === 0 || profilePackageBusy) return + const runningNames = profiles + .filter(profile => ids.includes(profile.profileId) && profile.running) + .map(profile => profile.profileName) + if (runningNames.length > 0) { + toast.error(`请先停止实例再导出:${runningNames.slice(0, 3).join('、')}${runningNames.length > 3 ? ' 等' : ''}`) + return + } + setProfilePackageBusy(true) + try { + const result = await exportBrowserProfilePackage(ids) + if (result.cancelled) return + toast.success(`已导出 ${result.profileCount} 个实例`) + } catch (error: any) { + toast.error(error?.message || '导出实例失败') + } finally { + setProfilePackageBusy(false) + } + } + + const handleExportProfile = async (profile: BrowserProfile) => { + if (profilePackageBusy) return + if (profile.running) { + toast.error(`请先停止实例再导出:${profile.profileName}`) + return + } + setProfilePackageBusy(true) + try { + const result = await exportBrowserProfilePackage([profile.profileId]) + if (result.cancelled) return + toast.success(`已导出:${profile.profileName}`) + } catch (error: any) { + toast.error(error?.message || '导出实例失败') + } finally { + setProfilePackageBusy(false) + } + } + + const handleImportProfiles = async () => { + if (profilePackageBusy) return + setProfilePackageBusy(true) + try { + const result = await importBrowserProfilePackage() + if (result.cancelled) return + toast.success(`已导入 ${result.importedCount} 个实例`) + setSelectedIds(new Set()) + await loadProfiles() + } catch (error: any) { + toast.error(error?.message || '导入实例失败') + } finally { + setProfilePackageBusy(false) + } + } + const openDeleteConfirm = (profileId: string) => { const profile = profiles.find(item => item.profileId === profileId) setDeleteConfirm({ @@ -419,6 +478,8 @@ export function BrowserListPage() { onRefresh={() => { void loadProfiles() }} onOpenSettings={handleOpenSettings} onOpenTrash={openTrashModal} + onImportProfiles={handleImportProfiles} + importingProfiles={profilePackageBusy} onOpenExpandModal={() => { setExpandModalOpen(true) loadQuota() @@ -434,8 +495,10 @@ export function BrowserListPage() { onDeselectAll={handleDeselectAll} onBatchStart={handleBatchStart} onBatchStop={handleBatchStop} + onBatchExport={handleBatchExport} onBatchDelete={openBatchDeleteConfirm} batchLoading={batchLoading} + exporting={profilePackageBusy} /> { void handleRestart(profileId) }} onOpenKeywords={openKwModal} onOpenExtensions={openExtensionModal} + onExport={(profile) => { void handleExportProfile(profile) }} onOpenCopy={openCopyModal} onOpenProxyPicker={setProxyPickerProfile} onDelete={openDeleteConfirm} diff --git a/frontend/src/modules/browser/pages/launchApiDocs/contentChangelog.ts b/frontend/src/modules/browser/pages/launchApiDocs/contentChangelog.ts index d89c28fe..2c8e93af 100644 --- a/frontend/src/modules/browser/pages/launchApiDocs/contentChangelog.ts +++ b/frontend/src/modules/browser/pages/launchApiDocs/contentChangelog.ts @@ -1,22 +1,22 @@ export const DOC_CHANGELOG = `# 更新日志 -## 1.3.0 - 2026-06-18 +## 1.3.0 - 2026-06-23 -- 插件包管理:支持商店链接 / 插件 ID 安装、本地插件包导入、本地插件目录导入、插件启停、删除和安装历史。 -- 插件实例限制:限制实例弹窗按分组展示实例,支持整组选择 / 取消,并显示每组已选数量。 -- 单实例插件配置:实例列表可进入单实例插件配置,支持从全局继承切换为独立插件清单。 -- 代理核心管理:补齐 xray、sing-box、mihomo 运行核心检测、下载、清理和启动锁处理。 -- 内核全局设置:全局设置只读字段统一为一致的灰底展示,减少普通值和多行参数的样式割裂。 -- 文档中心:补齐插件包管理、操作流程和全局设置说明,并接回脚本自动化章节内容。 +- 自动化增强:完善自动化脚本导入、运行、目标实例选择和执行记录管理。 +- 插件管理:支持插件安装、导入、启停、删除、实例限制和单实例插件配置。 +- VPN 优化:优化 Xray、sing-box、Mihomo 等连接栈的启动、测速、检测和预热能力。 +- 实例迁移:支持实例导入导出,可将实例配置和完整浏览器用户数据目录打包迁移到新环境。 +- 代理适配:实例导入时按代理名称匹配本地同名代理,匹配不到或同名不唯一时自动清空代理。 +- 界面优化:优化实例列表、关键字展示、操作菜单和导入导出入口。 -## 1.2.0 - 2026-05-05 +## 1.2.0 - 2026-05-09 -- 文档中心改版:把使用教程、内核、代理、接口和排障内容收进同一个入口,接口详情改为按章节逐步查看。 -- Launch API 完整化:补齐实例增删改查、按 code / selector 启动、runtime session、runtime status、runtime stop 和统一 CDP 入口。 -- 自动化脚本中心:支持脚本列表、详情、执行和运行记录;脚本可复用默认 selector / params,也可在执行时覆盖。 -- OpenClaw 对接:新增 ant-chrome-openclaw skill、安装脚本、HTTP 调用参考和同机远程 CDP 接管流程。 -- 代理能力增强:代理池支持更多导入与检测场景,补齐链式代理编辑、IP 健康检查和测速链路。 -- 工程拆分:实例启动、备份恢复、自动化运行时、发布脚本等模块拆分,减少单文件堆叠,便于后续维护。 +- 接口调用:Launch API 补齐实例增删改查、按 code / selector 启动、runtime session / status / stop 和统一 CDP 入口。 +- 自动化接口:脚本执行支持 selector / params 覆盖和 timeoutMs 超时控制,双实例 runtime 流程支持超时取消与错误返回。 +- 代理池增强:新增链式代理导入、编辑和预览能力,支持 HTTP / SOCKS5 两层链路,并优化直连代理批量导入。 +- 代理检测:新增测速目标、IP 健康检测目标和桥接启动超时配置,链式代理也可以参与测速与健康检测。 +- 实例启动:代理异常时支持本次直连启动,不修改实例原有代理配置;默认代理池只保留直连节点。 +- 书签能力:新增 IP 检测站点默认书签,支持设置启动时自动打开,并可同步到已有未运行实例。 ## 1.1.0 - 2026-03-19 diff --git a/frontend/src/modules/browser/pages/launchApiDocs/contentIntro.ts b/frontend/src/modules/browser/pages/launchApiDocs/contentIntro.ts index 1b75808d..361404ae 100644 --- a/frontend/src/modules/browser/pages/launchApiDocs/contentIntro.ts +++ b/frontend/src/modules/browser/pages/launchApiDocs/contentIntro.ts @@ -7,6 +7,7 @@ 代理池配置 -> 导入 / 添加代理 插件包管理 -> 安装 / 导入插件 实例列表 -> 新建实例 -> 选择内核 / 代理 -> 启动 +实例列表 -> 导入实例 / 选中实例 -> 导出 \`\`\` ## 最小上手顺序 @@ -18,6 +19,19 @@ 5. 打开 \`指纹浏览器 > 实例列表\`,新建实例并按需配置插件 6. 保存后直接启动 +## 实例迁移 + +\`\`\`text +导出:实例列表 -> 勾选实例 -> 导出 +单个导出:实例列表 -> 更多 -> 导出 +导入:实例列表 -> 导入实例 -> 选择 ZIP +\`\`\` + +- 导出前先停止实例,避免浏览器数据文件被占用 +- 导出包包含实例配置和完整浏览器用户数据目录 +- 导入时会生成新实例,不覆盖当前已有实例 +- 代理只按名称适配本地同名代理,未匹配时自动清空代理 + ## 最小 HTTP 对接 \`\`\`bash @@ -74,6 +88,7 @@ export const DOC_OPERATION_FLOW = `# 操作流程 3. 插件包管理:安装插件,按需限制实例 4. 实例列表:创建实例并选择内核、代理、分组 5. 启动实例:确认 debugReady 后再接管 +6. 迁移实例:停止实例后导出 ZIP,新环境中导入为新实例 \`\`\` ## 插件接入流程 diff --git a/frontend/src/modules/browser/types.ts b/frontend/src/modules/browser/types.ts index 65946841..f53e72c4 100644 --- a/frontend/src/modules/browser/types.ts +++ b/frontend/src/modules/browser/types.ts @@ -41,6 +41,21 @@ export interface BrowserProfileInput { groupId?: string } +export interface BrowserProfilePackageExportResult { + cancelled: boolean + zipPath: string + profileCount: number + fileCount: number + message: string +} + +export interface BrowserProfilePackageImportResult { + cancelled: boolean + importedCount: number + profileMappings: Record + message: string +} + export type BrowserProfileCopyMode = 'auto_fingerprint' | 'regular' export type BrowserProfileAutomationTarget = diff --git a/frontend/src/wailsjs/go/main/App.d.ts b/frontend/src/wailsjs/go/main/App.d.ts index f99f3e07..bf0ec6f6 100755 --- a/frontend/src/wailsjs/go/main/App.d.ts +++ b/frontend/src/wailsjs/go/main/App.d.ts @@ -1,4 +1,4 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT import {automation} from '../models'; import {backend} from '../models'; @@ -172,6 +172,10 @@ export function BrowserProfileList():Promise>; export function BrowserProfileListByTag(arg1:string):Promise>; +export function BrowserProfilePackageExport(arg1:Array):Promise; + +export function BrowserProfilePackageImport():Promise; + export function BrowserProfilePermanentlyDelete(arg1:string):Promise; export function BrowserProfileRegenerateCode(arg1:string):Promise; diff --git a/frontend/src/wailsjs/go/main/App.js b/frontend/src/wailsjs/go/main/App.js index dd1ad177..9f49c345 100755 --- a/frontend/src/wailsjs/go/main/App.js +++ b/frontend/src/wailsjs/go/main/App.js @@ -326,6 +326,14 @@ export function BrowserProfileListByTag(arg1) { return window['go']['main']['App']['BrowserProfileListByTag'](arg1); } +export function BrowserProfilePackageExport(arg1) { + return window['go']['main']['App']['BrowserProfilePackageExport'](arg1); +} + +export function BrowserProfilePackageImport() { + return window['go']['main']['App']['BrowserProfilePackageImport'](); +} + export function BrowserProfilePermanentlyDelete(arg1) { return window['go']['main']['App']['BrowserProfilePermanentlyDelete'](arg1); }